diff --git a/.docker/.env.identitylab.example b/.docker/.env.identitylab.example new file mode 100644 index 00000000..ca41146f --- /dev/null +++ b/.docker/.env.identitylab.example @@ -0,0 +1,8 @@ +IDENTITYLAB_IMAGE=ghcr.io/codebeamorg/ultimateauth-identitylab:REPLACE_WITH_SHA + +CADDY_PROXY_IP=172.20.0.4 +IDENTITYLAB_CONNECTION_STRING=Host=postgres;Port=5432;Database=identitylab;Username=identitylab_user;Password=REPLACE_WITH_PASSWORD + +LAB_ADMIN_USERNAME=Ladmin +LAB_ADMIN_EMAIL=REPLACE_WITH_EMAIL +LAB_ADMIN_PASSWORD=REPLACE_WITH_PASSWORD diff --git a/.docker/Dockerfile b/.docker/Dockerfile index d7011ee0..550d2a57 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -7,12 +7,12 @@ ENV CI=true COPY docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/*.csproj CodeBeam.Website/ COPY docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/*.csproj CodeBeam.Website.Client/ -RUN dotnet restore CodeBeam.Website/CodeBeam.UltimateAuth.Docs.Wasm.csproj +RUN dotnet restore CodeBeam.Website/CodeBeam.UltimateAuth.Docs.Wasm.csproj -p:UltimateAuthBuildTargetFrameworks=net10.0 COPY . . RUN dotnet publish docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj \ - -c Release -o /app/publish + -c Release -p:UltimateAuthBuildTargetFrameworks=net10.0 -o /app/publish # RUNTIME FROM mcr.microsoft.com/dotnet/aspnet:10.0 diff --git a/.docker/Dockerfile.identitylab b/.docker/Dockerfile.identitylab new file mode 100644 index 00000000..b786705d --- /dev/null +++ b/.docker/Dockerfile.identitylab @@ -0,0 +1,31 @@ +# Build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY . . + +RUN dotnet restore \ + apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj \ + -p:UltimateAuthBuildTargetFrameworks=net10.0 + +RUN dotnet publish \ + apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj \ + -c Release \ + -p:UltimateAuthBuildTargetFrameworks=net10.0 \ + --no-restore \ + -o /app/publish + +# Runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +ENV ASPNETCORE_HTTP_PORTS=8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +COPY --from=build /app/publish . + +EXPOSE 8080 + +USER $APP_UID + +ENTRYPOINT ["dotnet", "CodeBeam.UltimateAuth.IdentityLab.dll"] diff --git a/.docker/docker-compose.identitylab.yml b/.docker/docker-compose.identitylab.yml new file mode 100644 index 00000000..63621954 --- /dev/null +++ b/.docker/docker-compose.identitylab.yml @@ -0,0 +1,41 @@ +services: + identitylab: + image: ${IDENTITYLAB_IMAGE:?Identity Lab image is required} + container_name: identitylab + restart: unless-stopped + + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_HTTP_PORTS: "8080" + + ReverseProxy__KnownProxy: ${CADDY_PROXY_IP:?Caddy proxy IP is required} + ConnectionStrings__IdentityLab: ${IDENTITYLAB_CONNECTION_STRING:?Connection string is required} + + LabAdmin__Username: ${LAB_ADMIN_USERNAME:?Admin username is required} + LabAdmin__Email: ${LAB_ADMIN_EMAIL:?Admin email is required} + LabAdmin__Password: ${LAB_ADMIN_PASSWORD:?Admin password is required} + + DataProtection__KeysPath: /app/keys + + volumes: + - /opt/identitylab/keys:/app/keys:Z + + networks: + - edge + - postgres-internal + + security_opt: + - no-new-privileges:true + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + edge: + external: true + + postgres-internal: + external: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69e6789d..cebd62a9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,15 @@ -name: Deploy UltimateAuth +name: Docs Site on: + pull_request: push: branches: - master workflow_dispatch: concurrency: - group: ultimateauth-deploy + # PR updates may cancel their own validation, never a production deployment. + group: ${{ github.event_name == 'pull_request' && format('docs-site-pr-{0}', github.event.pull_request.number) || 'ultimateauth-deploy' }} cancel-in-progress: true permissions: @@ -15,35 +17,74 @@ permissions: packages: write jobs: - build-and-deploy: + changes: + name: Changes + if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + site: ${{ steps.filter.outputs.site }} + steps: + - name: Detect changed files + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + site: + - 'docs/**' + - 'src/utilities/CodeBeam.UltimateAuth.DocsBuilder/**' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'global.json' + - 'NuGet.Config' + - 'nuget.config' + - '.dockerignore' + - '.docker/Dockerfile' + - '.docker/docker-compose.yml' + - '.github/workflows/deploy.yml' + build: + name: Docker Build + needs: changes + # Master and manual builds do not depend on a PR-only detector succeeding. + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + (needs.changes.result == 'success' && needs.changes.outputs.site == 'true')) + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build and push image uses: docker/build-push-action@v6 with: context: . file: .docker/Dockerfile - push: true + push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master' }} tags: | ghcr.io/codebeamorg/ultimateauth:master ghcr.io/codebeamorg/ultimateauth:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + deploy: + name: Deploy + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: - name: Deploy to Server uses: appleboy/ssh-action@v1.0.3 with: diff --git a/.github/workflows/identitylab-image.yml b/.github/workflows/identitylab-image.yml new file mode 100644 index 00000000..fe17a6f3 --- /dev/null +++ b/.github/workflows/identitylab-image.yml @@ -0,0 +1,123 @@ +name: Identity Lab + +on: + pull_request: + push: + branches: + - lab + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + # PR validation cannot block or replace a production deployment run. + group: ${{ github.event_name == 'pull_request' && format('identitylab-pr-{0}', github.event.pull_request.number) || 'identitylab-deploy' }} + cancel-in-progress: false + +jobs: + changes: + name: Changes + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + image: ${{ steps.filter.outputs.image }} + steps: + - name: Checkout for push comparison + if: github.event_name != 'pull_request' + uses: actions/checkout@v4 + - name: Detect changed files + id: filter + uses: dorny/paths-filter@v3 + with: + base: ${{ github.ref }} + filters: | + image: + - 'apps/identity-lab/**' + - 'src/**' + - 'samples/**' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'global.json' + - 'NuGet.Config' + - 'nuget.config' + - '.dockerignore' + - '.docker/Dockerfile.identitylab' + - '.docker/docker-compose.identitylab.yml' + - '.github/workflows/identitylab-image.yml' + + build: + name: Docker Build + needs: changes + # Manual image builds must also run when the PR/push detector is skipped. + if: >- + !cancelled() && + (github.event_name == 'workflow_dispatch' || + (needs.changes.result == 'success' && needs.changes.outputs.image == 'true')) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Identity Lab + uses: docker/build-push-action@v6 + with: + context: . + file: .docker/Dockerfile.identitylab + push: ${{ github.event_name != 'pull_request' }} + tags: ghcr.io/codebeamorg/ultimateauth-identitylab:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/lab' + runs-on: ubuntu-latest + environment: identitylab + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Upload Identity Lab Compose + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: ".docker/docker-compose.identitylab.yml" + target: "/home/deploy/.identitylab-deploy/${{ github.sha }}" + strip_components: 1 + + - name: Deploy Identity Lab + uses: appleboy/ssh-action@v1.0.3 + env: + IMAGE_TAG: ghcr.io/codebeamorg/ultimateauth-identitylab:${{ github.sha }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + envs: IMAGE_TAG + command_timeout: 10m + script: | + SHA="${IMAGE_TAG##*:}" + COMPOSE_SOURCE="/home/deploy/.identitylab-deploy/$SHA/docker-compose.identitylab.yml" + + sudo -n /usr/local/sbin/deploy-identitylab \ + "$IMAGE_TAG" "$COMPOSE_SOURCE" + + rm -rf -- "/home/deploy/.identitylab-deploy/$SHA" diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml new file mode 100644 index 00000000..d791c003 --- /dev/null +++ b/.github/workflows/javascript-tests.yml @@ -0,0 +1,50 @@ +name: JS Client + +on: + pull_request: + push: + branches: + - dev + - master + +permissions: + contents: read + +jobs: + changes: + name: Changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + javascript: ${{ steps.filter.outputs.javascript }} + steps: + - name: Checkout for push comparison + if: github.event_name != 'pull_request' + uses: actions/checkout@v4 + - name: Detect changed files + id: filter + uses: dorny/paths-filter@v3 + with: + base: ${{ github.ref }} + filters: | + javascript: + - 'src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/**' + - 'tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/**' + - '.github/workflows/javascript-tests.yml' + + tests: + name: Tests + needs: changes + if: needs.changes.outputs.javascript == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run JavaScript tests + run: node --test tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/*.test.cjs diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index f7822605..5bbdf1d2 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -24,13 +24,26 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Resolve supported SDKs + id: frameworks + env: + DOTNET_NOLOGO: '1' + shell: bash + run: | + frameworks=$(dotnet msbuild src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj -nologo -getProperty:UltimateAuthTargetFrameworks) + { + echo 'sdks<> "$GITHUB_OUTPUT" + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - 9.0.x - 10.0.x + dotnet-version: ${{ steps.frameworks.outputs.sdks }} - name: Verify package version shell: bash diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml index 6058261f..92d96423 100644 --- a/.github/workflows/ultimateauth-ci.yml +++ b/.github/workflows/ultimateauth-ci.yml @@ -1,4 +1,4 @@ -name: UltimateAuth CI +name: UAuth CI on: push: @@ -8,54 +8,185 @@ on: tags: - 'v*' pull_request: - branches: - - dev - - 'release/**' workflow_dispatch: jobs: - build-test-coverage: - name: Build, Test & Coverage + frameworks: + name: Framework Validation runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.policy.outputs.matrix }} + latest: ${{ steps.policy.outputs.latest }} + latest-display: ${{ steps.policy.outputs.latest-display }} + sdks: ${{ steps.policy.outputs.sdks }} + solution-frameworks: ${{ steps.policy.outputs.solution-frameworks }} + steps: + - uses: actions/checkout@v4 + - name: Audit projects and generate compatibility matrix + id: policy + env: + DOTNET_NOLOGO: '1' + shell: bash + run: | + python3 - <<'PYTHON' + import concurrent.futures + import json + import os + from pathlib import Path + import re + import subprocess + import xml.etree.ElementTree as ET + + + def properties(project, names): + return json.loads(subprocess.check_output( + ["dotnet", "msbuild", str(project), "-nologo", "-getProperty:" + ",".join(names)], + text=True))["Properties"] + + config = properties("src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj", + ["UltimateAuthTargetFrameworks", "UltimateAuthLatestTargetFramework"]) + frameworks = config["UltimateAuthTargetFrameworks"].split(";") + assert frameworks and all(re.fullmatch(r"net\d+\.\d+", f) for f in frameworks), "Invalid supported frameworks" + version = lambda f: tuple(map(int, f[3:].split("."))) + assert frameworks == sorted(set(frameworks), key=version), "Frameworks must be unique and ascending" + latest = config["UltimateAuthLatestTargetFramework"] + assert latest == max(frameworks, key=version), "Incorrect latest test target" + projects = sorted(p for base in ("src", "samples", "apps", "docs", "tests") + for p in Path(base).rglob("*.csproj") if not {"obj", "bin"}.intersection(p.parts)) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + metadata = dict(zip(projects, pool.map(lambda p: properties( + p, ["TargetFramework", "TargetFrameworks", "IsPackable"]), projects))) + targets = lambda info: (info["TargetFrameworks"] or info["TargetFramework"]).split(";") + solution = {p.attrib["Path"].replace("\\", "/") for p in ET.parse("UltimateAuth.slnx").iter("Project")} + assert {p.as_posix() for p in projects} <= solution, "Projects are missing from the full solution build" + expected = {p for p in projects if p.parts[0] == "src" and "utilities" not in p.parts + and p.parent.name != "CodeBeam.UltimateAuth.Client.JsMinifier"} + expected.add(Path("samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj")) + packages = {p: info for p, info in metadata.items() if info["IsPackable"].lower() == "true"} + assert expected <= packages.keys(), "An expected library is missing or no longer packable" + for p in expected: + assert targets(packages[p]) == frameworks, f"Missing supported library target: {p}" + tests = [p for p in projects if p.parts[0] == "tests"] + assert tests, "No test projects discovered" + for p in tests: + assert targets(metadata[p]) == [latest], f"Tests must target only {latest}: {p}" + required = {latest} | {info["TargetFramework"] for info in metadata.values() if info["TargetFramework"]} + assert required <= set(frameworks), "Retarget single-target exceptions before dropping their library framework" + matrix = {"include": []} + for framework in frameworks: + included = [p.as_posix() for p, info in packages.items() if framework in targets(info)] + assert included, f"No packages selected for {framework}" + matrix["include"].append({"framework": framework, "display": framework[3:].removesuffix(".0"), + "sdk": framework[3:] + ".x", + "projects": "\n".join(included)}) + for p, info in packages.items(): + assert set(targets(info)) <= set(frameworks), f"Uncovered package framework: {p}" + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("matrix=" + json.dumps(matrix, separators=(",", ":")) + "\n") + output.write("latest=" + latest + "\n") + output.write("latest-display=" + latest[3:].removesuffix(".0") + "\n") + output.write("solution-frameworks=" + "%3B".join(sorted(required, key=version)) + "\n") + output.write("sdks<- - dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests + dotnet test --project tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj --configuration Release - --collect:"XPlat Code Coverage" - --logger "trx;LogFileName=identity-lab.trx" + --framework ${{ needs.frameworks.outputs.latest }} + -p:UltimateAuthBuildTargetFrameworks=${{ needs.frameworks.outputs.solution-frameworks }} + --coverlet + --coverlet-output-format cobertura + --coverlet-output-format json + --coverlet-include "[CodeBeam.UltimateAuth.IdentityLab]*" + --coverlet-exclude-by-file "**/Migrations/**/*.cs" + --report-trx + --report-trx-filename identity-lab.trx --results-directory TestResults/IdentityLab - name: Generate Identity Lab coverage summary @@ -86,7 +224,7 @@ jobs: import xml.etree.ElementTree as ET reports = glob.glob( - "TestResults/IdentityLab/**/coverage.cobertura.xml", + "TestResults/IdentityLab/**/coverage.cobertura*.xml", recursive=True ) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67cbcb91..130e1ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,3 +99,42 @@ Once the CLA process is implemented, contributors may be required to sign the ap Be respectful, assume good intentions, and help others learn. Different experience levels and perspectives make the project better. **Thank you for helping build UltimateAuth.** + +## Framework targeting and CI + +`UltimateAuthTargetFrameworks` in `Directory.Build.props` is the supported library +framework list. Keep it unique and in ascending version order. The last entry +becomes `UltimateAuthLatestTargetFramework`, which all five test projects inherit +from `tests/Directory.Build.props`. Tests, including rate-limiting compatibility +tests, run once on that latest framework; older frameworks receive package build +verification rather than repeated test runs. + +The 33 library projects under `src/` and the packable `Sample.Seed` project use +`UltimateAuthBuildTargetFrameworks`, which defaults to the supported list. CI can +select a supported build subset through that property without changing the +frameworks of single-target applications. `Sample.Shared.Hub` is a separate, +packable net10-only Razor library. Applications, other samples, docs, and the two +build tools retain their explicit net10 targets. Docker builds also restrict library +builds to their net10 application target, so adding a newer library framework +does not require upgrading those container SDKs. + +The CI policy job queries evaluated MSBuild properties for every project. It +checks solution membership, all expected library targets, and latest-only test +targets, then generates the compatibility matrix and an exact package/framework +table in the job summary. Each matrix job pins `global.json` **in its own checkout** +to the SDK installed for its framework, so a newer preinstalled SDK cannot hide +an older-SDK build failure. The committed `global.json` still selects MTP without +pinning an SDK. + +The test job retains the full solution build, but builds libraries only for the +latest framework and the frameworks needed by intentional single-target projects. +Identity Lab keeps its separate PostgreSQL job, TRX output, and assembly-scoped +coverage. The publishing workflow also derives its SDK list from the central +property. + +For example, changing the central list to `net10.0;net11.0` automatically generates +two SDK build jobs and moves all regular tests to net11. No workflow matrix edits +are needed. Framework-specific package references (notably Blazor, EF Core, and +TestHost) still need compatible versions when adding a new framework. Retarget +net10-only application/tool exceptions before eventually removing net10 itself; +CI rejects that omission rather than silently excluding projects. diff --git a/Directory.Build.props b/Directory.Build.props index 28108cd4..742ac4ab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,12 @@ - 0.2.0-preview.1 + + net8.0;net9.0;net10.0 + $([System.Text.RegularExpressions.Regex]::Match('$(UltimateAuthTargetFrameworks)', '[^;]+$').Value) + + $(UltimateAuthTargetFrameworks) + + 0.2.0-preview.2 $(NoWarn);1591 CodeBeam diff --git a/README.md b/README.md index abaaeb25..37dd42fc 100644 --- a/README.md +++ b/README.md @@ -29,34 +29,48 @@ --- -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. +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 | 07.04.2026 | +| Phase | Version | Scope | Status | Release Date | +| ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | +| First Preview | 0.1.0-preview | Preview Core | βœ… Completed | 07.04.2026 | | First Release* | 0.1.0 | Documented & Quality Tested (Client Public API Locked) | βœ… Completed | 12.10.2026 | -| Product Expansion | 0.2.0 | Full Auth Modes, Rate Limiting | 🟑 In Progress | Q4 2026 | -| Security Expansion | 0.3.0 | MFA, Reauth | 🟑 In Progress | Q4 2026 | -| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | πŸ”œ Planned | Q1 2027 | -| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | πŸ”œ Planned | Q1 2027 | -| Extensibility Expansion | 0.6.0 | Audit, events, hooks | πŸ”œ Planned | Q1 2027 | -| Performance Expansion | 0.7.0 | Benchmarks, caching | πŸ”œ Planned | Q1 2027 | -| Ecosystem Expansion | 0.8.0 | Migration tools | πŸ”œ Planned | Q2 2027 | -| v1.0 | 1.0.0 | Locked API, align with .NET 11 | πŸ”œ Planned | Q2 2027 | +| Product Expansion | 0.2.0 | Full Auth Modes, Rate Limiting | 🟑 In Progress | Q4 2026 | +| Security Expansion | 0.3.0 | MFA, Reauth | 🟑 In Progress | Q1 2027 | +| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hashers | πŸ”œ Planned | Q1 2027 | +| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | πŸ”œ Planned | Q2 2027 | +| Extensibility Expansion | 0.6.0 | Audit, events, hooks | πŸ”œ Planned | Q2 2027 | +| Performance Expansion | 0.7.0 | Benchmarks, caching | πŸ”œ Planned | Q3 2027 | +| Ecosystem Expansion | 0.8.0 | Migration tools | πŸ”œ Planned | Q3 2027 | +| v1.0 | 1.0.0 | Locked API, align with .NET 12 | πŸ”œ Planned | Q4 2027 | -*v 0.1.0 already provides a skeleton and extensibility points of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. +> **UltimateAuth was architected from its first release with extensibility points and foundational support for advanced capabilities, including multi-tenancy, MFA, and reauthentication. Subsequent releases build upon this existing architecture, expanding implementations, improvements and production-ready functionality rather than introducing these concepts from scratch.* > The project roadmap is actively maintained as a GitHub issue: - -πŸ‘‰ https://github.com/CodeBeamOrg/UltimateAuth/issues/8 +> +> [![GitHub Roadmap](https://img.shields.io/badge/GitHub-View_Roadmap-181717?logo=github&logoColor=white&style=flat-square)](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. +### Project Milestones + +UltimateAuth is designed to deliver a simple, consistent developer experience from its very first release. Each milestone expands its capabilities and supported scenarios, rather than introducing usability or architectural maturity from scratch. + +| Milestone | Version | Target | +|---|---|---| +| **Developer Foundation** | 0.1.x | A functional, security-conscious authentication framework with a complete core architecture, extensibility, and a developer-first API. Suitable for development, early adoption, and production use following application-specific evaluation. | +| **Production Expansion** | 0.2.0 | Ready to power real-world applications, particularly small and medium-sized products, with expanded authentication modes, built-in rate limiting, and a proven integration experience. Larger applications can also adopt UltimateAuth when its available capabilities meet their requirements. | +| **Advanced Security & Scale** | 0.4.0 | Extends production capabilities with MFA, reauthentication, distributed infrastructure, and additional security options for applications with more demanding requirements. Builds on the same simple developer experience. | +| **Migration & Adoption** | 0.8.0 | Makes adopting UltimateAuth in existing applications easier through migration tools, guidance, and established integration patterns, without requiring new applications to wait for this milestone. | + +**The UltimateAuth philosophy:** Simple from day one, more capable with every release. Version milestones describe expanding capabilities β€” not a progression from difficult to easy, or from unusable to usable. + --- + ### πŸ’œ Join the UltimateAuth Community UltimateAuth is more than a framework β€” it's a growing open-source community. @@ -347,7 +361,7 @@ Create test accounts, manage identities and sessions, explore roles and permissi **Powered by UltimateAuth, not simulated.** The demonstrated identity capabilities use the framework itself, so you can see what UltimateAuth can do before integrating it into your own application. -πŸ§ͺ **Identity Lab β€” Interactive Sandbox** *(Link coming soon)* +πŸ§ͺ **[Identity Lab](https://lab.ultimateauth.com)** --- diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index c1910ba6..cd8f9614 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -31,6 +31,7 @@ + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj index 5987c73f..de41bbeb 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj @@ -10,14 +10,14 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor new file mode 100644 index 00000000..ae39e1b0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor @@ -0,0 +1,98 @@ +ο»Ώ@using CodeBeam.UltimateAuth.Credentials.Contracts + +@if (Policy is not null) +{ +
+
+ + Password requirements + + + + @Policy.Level + + +
+ +
+ @foreach (var rule in Rules) + { +
+ + + @rule.Label + +
+ } +
+ + @if (Policy.CheckCommonPasswords || + Policy.CheckUserSimilarity || + Policy.CheckPasswordHistory) + { + + Additional security checks are performed when you submit. + + } +
+} + +@code { + [Parameter, EditorRequired] + public UAuthPasswordPolicyInfo? Policy { get; set; } + + [Parameter] + public string? Password { get; set; } + + + private IEnumerable<(string Label, bool Passed)> Rules + { + get + { + if (Policy is null) + yield break; + + var password = Password ?? string.Empty; + var hasPassword = password.Length > 0; + + yield return ( + $"At least {Policy.MinLength} characters", + hasPassword && password.Length >= Policy.MinLength); + + yield return ( + $"At most {Policy.MaxLength} characters", + hasPassword && password.Length <= Policy.MaxLength); + + if (Policy.RequireLetter) + yield return ( + "Contains a letter", + hasPassword && password.Any(char.IsLetter)); + + if (Policy.RequireDigit) + yield return ( + "Contains a number", + hasPassword && password.Any(char.IsDigit)); + + if (Policy.RequireUppercase) + yield return ( + "Contains an uppercase letter", + hasPassword && password.Any(char.IsUpper)); + + if (Policy.RequireLowercase) + yield return ( + "Contains a lowercase letter", + hasPassword && password.Any(char.IsLower)); + + if (Policy.RequireSpecialCharacter) + yield return ( + "Contains a special character", + hasPassword && password.Any(c => !char.IsLetterOrDigit(c))); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css new file mode 100644 index 00000000..a6af4c2e --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabPasswordRequirements.razor.css @@ -0,0 +1,50 @@ +.lab-password-policy { + padding: 14px 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background-gray); +} + +.lab-password-policy-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 700; + color: var(--mud-palette-text-primary); +} + + .lab-password-policy-header .mud-chip { + margin-left: auto; + } + +.lab-password-policy-rules { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px 12px; + margin-bottom: 10px; +} + +.lab-password-policy-rule { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 12px; + color: var(--mud-palette-text-secondary); +} + + .lab-password-policy-rule .mud-icon-root { + flex-shrink: 0; + } + +.rule-passed { + color: var(--mud-palette-success); +} + +@media (max-width: 480px) { + .lab-password-policy-rules { + grid-template-columns: 1fr; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor index fbb8cedb..54c68294 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor @@ -118,85 +118,110 @@ - -
WORKSPACE
+
+ +
WORKSPACE
+ + + + Platform Administration + + - - - Platform Administration + + Overview - - - Overview - +
PERSONAL
-
PERSONAL
+ + My Identity + - - My Identity - + + My Sessions + - - My Sessions - + + Session Diagnostics + - - Session Diagnostics - + + My Access + - - My Access - + + Authorization Lab + - - Authorization Lab - + @if (ShowManagement) + { +
SANDBOX MANAGEMENT
- @if (ShowManagement) - { -
SANDBOX MANAGEMENT
+ + Users + - - Users - + + Authorization + - - Authorization - + + Activity + - - Activity - + + Settings + + } - - Settings - - } + + + Sign Out + + - - - Sign Out +
PLAYGROUND
+ + + Demo Store
+
-
PLAYGROUND
- - - Demo Store - -
- - - -
- - Powered by UltimateAuth + - - v @UltimateAuthVersion - .NET @LabVersionInfo.DotNetVersion -
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css index 00097497..efe385e9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css @@ -54,8 +54,9 @@ .lab-sidebar-content { display: flex; flex-direction: column; - min-height: 100%; - padding: 20px 12px; + height: 100%; + min-height: 0; + padding: 20px 12px 12px; } .lab-sidebar-sandbox { @@ -117,15 +118,118 @@ font-weight: 650; } -.lab-sidebar-bottom { + +/* Scrollable navigation */ + +.lab-sidebar-navigation { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--mud-palette-lines-default) transparent; +} + +/* Product footer */ + +.lab-sidebar-footer { + flex-shrink: 0; + padding-top: 12px; + margin-top: 12px; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-sidebar-product { + padding: 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-surface); +} + +.lab-sidebar-product-brand { + display: flex; + align-items: center; + gap: 9px; + color: var(--mud-palette-text-primary); + text-decoration: none; + min-width: 0; +} + +.lab-sidebar-product-icon { + width: 36px; + height: 36px; + flex-shrink: 0; display: flex; align-items: center; - gap: 8px; - padding: 18px 12px 4px; + justify-content: center; + border-radius: 9px; + background: var(--mud-palette-primary-hover); +} + +.lab-sidebar-product-info { + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; + min-width: 0; +} + + .lab-sidebar-product-info strong { + font-size: 0.75rem; + font-weight: 700; + line-height: 1.3; + white-space: nowrap; + } + + .lab-sidebar-product-info small { + font-size: 0.66rem; + color: var(--mud-palette-text-secondary); + } + +.lab-sidebar-product-external { + flex-shrink: 0; color: var(--mud-palette-text-secondary); - font-size: .72rem; + opacity: 0.65; +} + +.lab-sidebar-product-brand:hover .lab-sidebar-product-external { + color: var(--mud-palette-primary); + opacity: 1; } +/* Version information */ + +.lab-sidebar-product-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-sidebar-product-meta a, + .lab-sidebar-product-runtime { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + color: var(--mud-palette-text-secondary); + font-size: 0.65rem; + white-space: nowrap; + text-decoration: none; + } + + .lab-sidebar-product-meta .mud-icon-root { + font-size: 14px; + } + + .lab-sidebar-product-meta a:hover { + color: var(--mud-palette-primary); + } + + .lab-dashboard-main { min-height: calc(100vh - var(--mud-appbar-height)); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor index 2f27fb8b..5a23e614 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor @@ -211,7 +211,7 @@ @foreach (var group in Scenarios.GroupBy(x => x.Group)) { - + @foreach (var scenario in group) { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor index e90a060f..8c730d92 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor @@ -1,8 +1,8 @@ ο»Ώ@page "/dashboard/identity" -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.IdentityLab.Services -@using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Server.Options +@using Microsoft.Extensions.Options @layout DashboardLayout +@inject IOptions ServerOptions @inject SandboxAccountDeletionService SandboxAccountDeletionService @inject LabDashboardService LabDashboardService @inject IUAuthClient UAuthClient @@ -55,43 +55,26 @@ @if (_loading) { - - - - - - - - - + + + + + } else if (_loadError is not null) { - + @_loadError - + Try Again } else if (_profile is null) { - + Profile information is currently unavailable. } @@ -239,14 +222,22 @@
-

Sign-in Identifiers

-

- Identifiers associated with your account. -

+

+ Sign-in Identifiers + + @if (!_identifiersLoading && _identifiersError is null) + { + + @ActiveIdentifierCount/@MaxIdentifiers + + } +

+ +

Identifiers associated with your account.

- +
@@ -458,6 +449,10 @@ private string? _identifiersError; private bool _isSandboxOwner; + private int MaxIdentifiers => ServerOptions.Value.Identifiers.Behavior.MaxIdentifiersPerUser; + private int ActiveIdentifierCount => _identifiers.Count; + private bool IdentifierLimitReached => ActiveIdentifierCount >= MaxIdentifiers; + private static string FormatValue(string? value) => string.IsNullOrWhiteSpace(value) ? "Not provided" : value; protected override async Task OnInitializedAsync() @@ -591,7 +586,18 @@ "Change Password", new DialogParameters { - { x => x.PreviewMode, false } + { x => x.PreviewMode, false }, + { + x => x.PasswordRequirementsContent, + (RenderFragment)(context => + builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Policy", context.Policy); + builder.AddAttribute(2, "Password", context.Password); + builder.CloseComponent(); + }) + } }, UAuthDialog.GetDialogOptions(MaxWidth.Small)); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor index 5c2cecf4..180b806a 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor @@ -317,7 +317,18 @@ var parameters = new DialogParameters { { x => x.PreviewMode, false }, - { x => x.OnCreateUserAsync, CreateSandboxUserAsync } + { x => x.OnCreateUserAsync, CreateSandboxUserAsync }, + { + x => x.PasswordRequirementsContent, + (RenderFragment)(context => + builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Policy", context.Policy); + builder.AddAttribute(2, "Password", context.Password); + builder.CloseComponent(); + }) + } }; var dialog = await DialogService.ShowAsync( diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor index fc4a05b5..d512cd87 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor @@ -107,7 +107,7 @@ - + @if (IsLockedOut) { @@ -127,10 +127,7 @@ @LockoutCountdown - + Try again after @@ -187,7 +184,7 @@ MaxLength="256" Required="true" RequiredError="Email or username is required." - Disabled="IsLockedOut" + Disabled="@_loginBusy" Immediate="true" /> - @(IsLockedOut ? "Temporarily locked" : "Sign In") - +
@@ -315,8 +314,15 @@ ?? AuthState.Identity?.PrimaryUserName ?? "User"; - private string? CurrentUserName => - AuthState.Identity?.PrimaryUserName; + private string? CurrentUserName => AuthState.Identity?.PrimaryUserName; + + private bool _loginBusy; + + private async Task HandleSubmittingChanged(bool submitting) + { + _loginBusy = submitting; + await InvokeAsync(StateHasChanged); + } private void ContinueToDashboard() { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor index 35140163..d978ccf5 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor @@ -1,9 +1,12 @@ ο»Ώ@page "/register" +@using CodeBeam.UltimateAuth.Core.Errors + @layout PublicLayout @implements IDisposable @inject LabRegistrationService RegistrationService @inject LabRegistrationAvailabilityService AvailabilityService +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject NavigationManager Navigation @@ -122,6 +125,23 @@ autocomplete="new-password" Validation="@(new Func(ValidatePassword))" Disabled="_isSubmitting" /> + + @if (_passwordPolicy is not null) + { + + } + else if (_policyLoading) + { + + Loading password requirements... + + } + else + { + + Password requirements will be verified by the server. + + } PasswordPolicyLimits.AbsoluteMaxLength) + return "Password exceeds the maximum allowed length."; + + if (_passwordPolicy is null) + return null; + + var violations = UAuthPasswordClientValidator.Validate(value, _passwordPolicy); + + if (violations.Count == 0) + return null; + + return violations[0] switch + { + PasswordPolicyErrorCodes.TooShort => + $"Password must contain at least {_passwordPolicy.MinLength} characters.", + + PasswordPolicyErrorCodes.TooLong => + $"Password cannot exceed {_passwordPolicy.MaxLength} characters.", + + PasswordPolicyErrorCodes.LetterRequired => + "Password must contain a letter.", + + PasswordPolicyErrorCodes.DigitRequired => + "Password must contain a number.", + + PasswordPolicyErrorCodes.UppercaseRequired => + "Password must contain an uppercase letter.", + + PasswordPolicyErrorCodes.LowercaseRequired => + "Password must contain a lowercase letter.", - return null; + PasswordPolicyErrorCodes.SpecialCharacterRequired => + "Password must contain a special character.", + + PasswordPolicyErrorCodes.InputTooLong => + "Password exceeds the maximum allowed length.", + + _ => "Password does not meet the requirements." + }; } private string? ValidateConfirmPassword(string value) @@ -436,10 +514,7 @@ return Task.CompletedTask; } - private async Task CheckUsernameAsync( - string value, - int version, - CancellationTokenSource cts) + private async Task CheckUsernameAsync(string value, int version, CancellationTokenSource cts) { var token = cts.Token; @@ -495,10 +570,7 @@ } } - private async Task CheckEmailAsync( - string value, - int version, - CancellationTokenSource cts) + private async Task CheckEmailAsync(string value, int version, CancellationTokenSource cts) { var token = cts.Token; @@ -567,6 +639,14 @@ if (!_form.IsValid) return; + var passwordError = ValidatePassword(_password); + + if (passwordError is not null) + { + _errorMessage = passwordError; + return; + } + if (_birthDate is { } birthDate && birthDate.Date > DateTime.Today) { @@ -630,6 +710,10 @@ _registered = true; } + catch (UAuthValidationException ex) + { + _errorMessage = LabPasswordErrorMessages.Get(ex.Code); + } catch (Exception) { _errorMessage = "An unexpected error occurred. If you already submitted your registration, try signing in before registering again."; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 38cdf158..ecfb50ae 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -25,6 +25,7 @@ @using CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Client.Diagnostics @using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts @using CodeBeam.UltimateAuth.Authorization.Contracts @using CodeBeam.UltimateAuth.Sample.Shared.Common diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs new file mode 100644 index 00000000..15f9894a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabPasswordErrorMessages.cs @@ -0,0 +1,42 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.IdentityLab.Contracts; + +public static class LabPasswordErrorMessages +{ + public static string Get(string? code) => code switch + { + PasswordPolicyErrorCodes.TooShort => + "Your password is too short.", + + PasswordPolicyErrorCodes.TooLong or + PasswordPolicyErrorCodes.InputTooLong => + "Your password exceeds the allowed length.", + + PasswordPolicyErrorCodes.LetterRequired => + "Your password must contain a letter.", + + PasswordPolicyErrorCodes.DigitRequired => + "Your password must contain a number.", + + PasswordPolicyErrorCodes.UppercaseRequired => + "Your password must contain an uppercase letter.", + + PasswordPolicyErrorCodes.LowercaseRequired => + "Your password must contain a lowercase letter.", + + PasswordPolicyErrorCodes.SpecialCharacterRequired => + "Your password must contain a special character.", + + PasswordPolicyErrorCodes.CommonPassword => + "This password is too common. Please choose a less predictable password.", + + PasswordPolicyErrorCodes.SimilarToUser => + "Your password is too similar to your username or email address.", + + PasswordPolicyErrorCodes.ReusedPassword => + "You cannot reuse a previous password.", + + _ => "Your password does not meet the security requirements." + }; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.Designer.cs new file mode 100644 index 00000000..0a45ba86 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.Designer.cs @@ -0,0 +1,742 @@ +ο»Ώ// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261031185511_AddProfileFieldLimits")] + partial class AddProfileFieldLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + 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/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.cs new file mode 100644 index 00000000..67d4fada --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261031185511_AddProfileFieldLimits.cs @@ -0,0 +1,178 @@ +ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + /// + public partial class AddProfileFieldLimits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TimeZone", + table: "UAuth_UserProfiles", + type: "character varying(128)", + maxLength: 128, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LastName", + table: "UAuth_UserProfiles", + type: "character varying(128)", + maxLength: 128, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Language", + table: "UAuth_UserProfiles", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Gender", + table: "UAuth_UserProfiles", + type: "character varying(128)", + maxLength: 128, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FirstName", + table: "UAuth_UserProfiles", + type: "character varying(128)", + maxLength: 128, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DisplayName", + table: "UAuth_UserProfiles", + type: "character varying(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Culture", + table: "UAuth_UserProfiles", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Bio", + table: "UAuth_UserProfiles", + type: "character varying(4096)", + maxLength: 4096, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TimeZone", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LastName", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Language", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Gender", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FirstName", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DisplayName", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Culture", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Bio", + table: "UAuth_UserProfiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(4096)", + oldMaxLength: 4096, + oldNullable: true); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..583c63e0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.Designer.cs @@ -0,0 +1,778 @@ +ο»Ώ// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261102060817_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialId") + .HasColumnType("uuid"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + 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/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs new file mode 100644 index 00000000..c00bab6a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261102060817_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CredentialId = table.Column(type: "uuid", nullable: false), + SecretHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs index 7b6b4d47..55d8efe3 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs @@ -249,6 +249,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialId") + .HasColumnType("uuid"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") @@ -641,7 +677,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid"); b.Property("Bio") - .HasColumnType("text"); + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); b.Property("BirthDate") .HasColumnType("date"); @@ -650,25 +687,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("Culture") - .HasColumnType("text"); + .HasMaxLength(64) + .HasColumnType("character varying(64)"); b.Property("DeletedAt") .HasColumnType("timestamp with time zone"); b.Property("DisplayName") - .HasColumnType("text"); + .HasMaxLength(256) + .HasColumnType("character varying(256)"); b.Property("FirstName") - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Gender") - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Language") - .HasColumnType("text"); + .HasMaxLength(64) + .HasColumnType("character varying(64)"); b.Property("LastName") - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Metadata") .HasColumnType("text"); @@ -684,7 +727,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(128)"); b.Property("TimeZone") - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDatabaseMigrator.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDatabaseMigrator.cs new file mode 100644 index 00000000..cd874ec2 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDatabaseMigrator.cs @@ -0,0 +1,32 @@ +ο»Ώusing CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.IdentityLab.Persistence; + +public static class LabDatabaseMigrator +{ + public static async Task MigrateAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + + var logger = scope.ServiceProvider + .GetRequiredService() + .CreateLogger("IdentityLab.Migrations"); + + logger.LogInformation("Starting Identity Lab database migrations."); + + var uauthDb = scope.ServiceProvider.GetRequiredService(); + + await uauthDb.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("UltimateAuth database migrations completed."); + + var labDbFactory = scope.ServiceProvider.GetRequiredService>(); + + await using var labDb = await labDbFactory.CreateDbContextAsync(cancellationToken); + + await labDb.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("Identity Lab database migrations completed."); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 1e6c15e7..2a34f7a9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -16,6 +16,8 @@ using CodeBeam.UltimateAuth.IdentityLab.Services; using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure; using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using MudExtensions.Services; @@ -108,8 +110,40 @@ builder.Services.AddScoped(); builder.Services.AddLogging(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; + + if (builder.Environment.IsProduction()) + { + var trustedProxy = builder.Configuration["ReverseProxy:KnownProxy"] + ?? throw new InvalidOperationException("ReverseProxy:KnownProxy must be configured in production."); + + if (!System.Net.IPAddress.TryParse(trustedProxy, out var proxyAddress)) + { + throw new InvalidOperationException("ReverseProxy:KnownProxy must be a valid IP address."); + } + + options.KnownProxies.Add(proxyAddress); + } +}); + +if (builder.Environment.IsProduction()) +{ + var keysPath = builder.Configuration["DataProtection:KeysPath"] + ?? throw new InvalidOperationException("DataProtection:KeysPath must be configured in production."); + + builder.Services.AddDataProtection() + .SetApplicationName("UltimateAuth.IdentityLab") + .PersistKeysToFileSystem(new DirectoryInfo(keysPath)); +} + var app = builder.Build(); +app.UseForwardedHeaders(); + activityEventBridge.Initialize(app.Services); // Configure the HTTP request pipeline. @@ -133,6 +167,12 @@ .AddInteractiveServerRenderMode() .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); +if (args.Contains("--migrate", StringComparer.OrdinalIgnoreCase)) +{ + await LabDatabaseMigrator.MigrateAsync(app.Services); + return; +} + await app.SeedIdentityLabAsync(); app.Run(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs index 6e7f4994..94af2313 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; @@ -27,21 +28,31 @@ public async Task RegisterAsync(LabRegistrationRequest re var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); - var createResult = await userApplicationService.CreateUserAsync(trustedContext, - new CreateUserRequest - { - UserName = request.Username.Trim(), - Email = request.Email.Trim(), - Password = request.Password, - FirstName = string.IsNullOrWhiteSpace(request.FirstName) - ? null - : request.FirstName.Trim(), - LastName = string.IsNullOrWhiteSpace(request.LastName) - ? null - : request.LastName.Trim(), - BirthDate = request.BirthDate - }, - ct); + + UserCreateResult? createResult; // Burada mevcut metodun gerΓ§ek dΓΆnüş tipini kullan. + + try + { + createResult = await userApplicationService.CreateUserAsync( + trustedContext, + new CreateUserRequest + { + UserName = request.Username.Trim(), + Email = request.Email.Trim(), + Password = request.Password, + FirstName = string.IsNullOrWhiteSpace(request.FirstName) + ? null : request.FirstName.Trim(), + LastName = string.IsNullOrWhiteSpace(request.LastName) + ? null : request.LastName.Trim(), + BirthDate = request.BirthDate + }, + ct); + } + catch (UAuthValidationException ex) + { + return LabRegistrationResult.Failed(LabPasswordErrorMessages.Get(ex.Code)); + } + if (!createResult.IsSuccess || string.IsNullOrWhiteSpace(createResult.UserKey)) diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs index 858f630f..91f807b1 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -20,26 +20,19 @@ public sealed class LabUserProvisioningService( LabProvisioningRollbackGate rollbackGate, ILogger logger) { - public async Task CreateMemberAsync( - UserKey actorUserKey, - CreateUserRequest request, - CancellationToken ct = default) + public async Task CreateMemberAsync(UserKey actorUserKey, CreateUserRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); // Obtain the actor from the actual authenticated session. - var context = await contextProvider.CreateAsync( - UAuthActions.Users.CreateAdmin, - "users", - ct); + var context = await contextProvider.CreateAsync(UAuthActions.Users.CreateAdmin, "users", ct); if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey authenticatedUserKey || authenticatedUserKey != actorUserKey) { - throw new UnauthorizedAccessException( - "Authenticated actor mismatch."); + throw new UnauthorizedAccessException("Authenticated actor mismatch."); } var sandbox = await sandboxService.GetByUserAsync( @@ -54,16 +47,12 @@ context.ActorUserKey is not UserKey authenticatedUserKey || ct); if (!access.Allowed) - throw new UnauthorizedAccessException( - "Sandbox management permission required."); + throw new UnauthorizedAccessException("Sandbox management permission required."); - var remaining = await sandboxService.GetRemainingCapacityAsync( - sandbox.Id, - ct); + var remaining = await sandboxService.GetRemainingCapacityAsync(sandbox.Id, ct); if (remaining <= 0) - throw new InvalidOperationException( - "Sandbox user limit reached."); + throw new InvalidOperationException("Sandbox user limit reached."); // Fail before account creation if the required role is absent. var roleStore = roleStoreFactory.Create( @@ -76,9 +65,7 @@ context.ActorUserKey is not UserKey authenticatedUserKey || "Identity Lab User role is not configured."); // Only this server-side path can attach the object-identity token. - var trustedContext = context.WithAttribute( - LabProvisioningGate.AttributeKey, - LabProvisioningGate.Token); + var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); var result = await userApplicationService.CreateUserAsync(trustedContext, request, ct); diff --git a/docs/content/auth-flows/logout-flow.md b/docs/content/auth-flows/logout-flow.md index 1b9baaa9..34385342 100644 --- a/docs/content/auth-flows/logout-flow.md +++ b/docs/content/auth-flows/logout-flow.md @@ -153,6 +153,33 @@ With redirect disabled, the ordinary endpoint returns: With redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case. +### Client-controlled navigation + +The UltimateAuth Client API allows applications to control navigation after a successful logout through the optional `navigate` parameter. + +By default, logout follows the server-configured redirect behavior: + +```csharp +await UAuthClient.Flows.LogoutAsync(); +``` + +To complete logout without automatic navigation, set `navigate` to `false`: + +```csharp +await UAuthClient.Flows.LogoutAsync(navigate: false); + +// Perform application-specific cleanup after logout. +await YourApplicationCleanupAsync(); + +Navigation.NavigateTo("/goodbye"); +``` + +When `navigate` is `false`, UltimateAuth still performs the server-side logout, clears applicable authentication cookies, and publishes the logout event so the client authentication state can be cleared. Only automatic navigation is suppressed. + +This is useful when an application needs to clear its own state, perform additional cleanup, display a confirmation, or choose a destination after logout. + +**Important:** The `navigate` parameter controls client-side navigation, not session termination or server-side redirect configuration. If logout fails, including with HTTP 429, the operation does not publish a successful logout event. Applications should perform post-logout actions only after `LogoutAsync` completes successfully. + ## Why redirect matters A redirect is **not required to invalidate authentication state**. It is a navigation concern that becomes important in browser-based authentication flows. diff --git a/docs/content/auth-flows/overview.md b/docs/content/auth-flows/overview.md index a3565f06..6f76f7cb 100644 --- a/docs/content/auth-flows/overview.md +++ b/docs/content/auth-flows/overview.md @@ -210,9 +210,9 @@ The following pages explain each operation in detail. This overview focuses on t | Flow | Purpose | Practical outcome | Guide | | --- | --- | --- | --- | -| **Login** | Establish authenticated access from acceptable credentials. | Creates a valid authentication session in session-issuing modes; may issue tokens according to policy. | [Login Flow](./login-flow.md) | -| **Refresh** | Continue an existing authentication relationship using renewal authority. | Maintains or renews access according to mode, validation, and configured refresh behavior. | [Refresh Flow](./refresh-flow.md) | -| **Logout** | End access at the applicable session or device scope. | Terminates authentication access and handles configured client cleanup/navigation. | [Logout Flow](./logout-flow.md) | +| **Login** | Establish authenticated access from acceptable credentials. | Creates a valid authentication session in session-issuing modes; may issue tokens according to policy. | [Login Flow](/docs/auth-flows/login-flow) | +| **Refresh** | Continue an existing authentication relationship using renewal authority. | Maintains or renews access according to mode, validation, and configured refresh behavior. | [Refresh Flow](/docs/auth-flows/refresh-flow) | +| **Logout** | End access at the applicable session or device scope. | Terminates authentication access and handles configured client cleanup/navigation. | [Logout Flow](/docs/auth-flows/logout-flow) | These flows are related but not interchangeable. Login establishes access; refresh continues it; logout terminates it. Revocation operations provide stronger invalidation controls for the relevant security scope. @@ -220,9 +220,9 @@ These flows are related but not interchangeable. Login establishes access; refre | Concept | What it contributes | Guide | | --- | --- | --- | -| **Session lifecycle** | Root, chain, and session relationships; creation, continuity, expiry, and invalidation. | [Session Lifecycle](./session-lifecycle.md) | -| **Token behavior** | Access and refresh credentials, validation, renewal, and mode-specific behavior. | [Token Behavior](./token-behavior.md) | -| **Device management** | Device-associated chains, multi-device access, and targeted termination. | [Device Management](./device-management.md) | +| **Session lifecycle** | Root, chain, and session relationships; creation, continuity, expiry, and invalidation. | [Session Lifecycle](/docs/sessions-devices/session-lifecycle) | +| **Token behavior** | Access and refresh credentials, validation, renewal, and mode-specific behavior. | [Token Behavior](/docs/security/token-security) | +| **Device management** | Device-associated chains, multi-device access, and targeted termination. | [Device Management](/docs/sessions-devices/device-management) | ## What this design enables in real applications @@ -246,4 +246,4 @@ This is the larger goal of UltimateAuth's unified modelβ€”not to make every auth ## Next step -Start with [Login Flow](/docs/auth-flows/login) to see how UltimateAuth turns validated credentials into controlled authenticated access. +Start with [Login Flow](/docs/auth-flows/login-flow) to see how UltimateAuth turns validated credentials into controlled authenticated access. diff --git a/docs/content/client-sdk/authentication.md b/docs/content/client-sdk/authentication.md index 88ab5fb8..5bbb830c 100644 --- a/docs/content/client-sdk/authentication.md +++ b/docs/content/client-sdk/authentication.md @@ -1263,7 +1263,7 @@ For application-specific workflows that cannot be expressed through the componen ## Next Steps -- [Session Management](/docs/client/session-management) β€” Inspect and manage session and device information. +- [Session Management](/docs/client-sdk/session-management) β€” Inspect and manage session and device information. - [Token Security](/docs/security/token-security) β€” Understand token validity, refresh behavior, and revocation. - [Refresh Rotation & Replay Protection](/docs/security/refresh-rotation) β€” Explore refresh concurrency and reuse security. - [Authentication Modes](/docs/fundamentals/auth-modes) β€” Compare authentication behavior across modes. diff --git a/docs/content/client-sdk/authorization.md b/docs/content/client-sdk/authorization.md index 05be2f25..886f9532 100644 --- a/docs/content/client-sdk/authorization.md +++ b/docs/content/client-sdk/authorization.md @@ -1310,7 +1310,7 @@ if (result.IsSuccess) This pattern works well alongside `UAuthClient.Users` for building a unified user administration interface. -See [User Management](/docs/client/user-management). +See [User Management](/docs/client-sdk/user-management). ## 20. Authorization State Events @@ -1373,7 +1373,7 @@ Revoking a session changes whether an authentication session remains valid. Applications may combine these operations when security policy requires immediate termination of access, but the concepts should remain separate. -See [Session Management](/docs/client/session-management). +See [Session Management](/docs/client-sdk/session-management). ## 22. Multi-Tenant Authorization @@ -1544,8 +1544,8 @@ This allows applications to provide flexible administrative interfaces while kee ## Next Steps -- [Policies & Access Control](/docs/plugin-domains/policies-access-control) β€” Understand application policies and access rules. -- [Authorization Pipeline Internals](/docs/security/authorization-pipeline-internals) β€” Explore how authorization decisions are evaluated. -- [User Management](/docs/client/user-management) β€” Manage user accounts and lifecycle. -- [Session Management](/docs/client/session-management) β€” Manage authenticated sessions and devices. +- [Policies & Access Control](/docs/plugin-domains/policies) β€” Understand application policies and access rules. +- [Authorization Pipeline Internals](/docs/security/authorization-pipeline) β€” Explore how authorization decisions are evaluated. +- [User Management](/docs/client-sdk/user-management) β€” Manage user accounts and lifecycle. +- [Session Management](/docs/client-sdk/session-management) β€” Manage authenticated sessions and devices. - [Session Security Model](/docs/security/session-security) β€” Understand session validity and security boundaries. \ No newline at end of file diff --git a/docs/content/client-sdk/credentials.md b/docs/content/client-sdk/credentials.md index 0523b2a4..7b665805 100644 --- a/docs/content/client-sdk/credentials.md +++ b/docs/content/client-sdk/credentials.md @@ -1068,6 +1068,48 @@ The existence of a `CredentialType` enum member does not establish that its comp Use the currently implemented credential contracts and documented authentication flows. +## Retrieving Password Requirements + +UltimateAuth clients can retrieve the effective password policy configured by the authentication server. + +This allows applications to display the current requirements during registration, user creation, and password changes without hardcoding password rules in the UI. + +```csharp +var result = await UAuthClient.Credentials.GetPasswordPolicyAsync(); + +if (result.IsSuccess && result.Value is { } policy) +{ + // Display the effective requirements. + var minimumLength = policy.MinLength; + var maximumLength = policy.MaxLength; + var level = policy.Level; +} +``` + +The response includes the configured policy level, effective length and character requirements, and indicators for server-side common-password, user-similarity, and password-history checks. + +### Client-Side Validation + +Use `UAuthPasswordClientValidator` to provide immediate feedback before submitting a password: + +```csharp +using CodeBeam.UltimateAuth.Client; + +var errors = UAuthPasswordClientValidator.Validate(password, policy); + +if (errors.Count == 0) +{ + // Local character and length requirements are satisfied. + // The server must still validate the submitted password. +} +``` + +The validator returns `PasswordPolicyErrorCodes` values for failed local requirements. + +**Client-side validation is advisory, not authoritative.** The server remains responsible for the complete password-policy decision, including common-password detection, user similarity, and password history. + +For the built-in Basic, Standard, and Strict requirements, configuration examples, and password-history behavior, see [Password Policy & History](/docs/security/password-policy). + ## Next Steps - [Authentication](/docs/client/authentication) β€” Login, logout, refresh, and authentication flows. diff --git a/docs/content/client-sdk/identifiers.md b/docs/content/client-sdk/identifiers.md index 4e6fbec7..e0e82737 100644 --- a/docs/content/client-sdk/identifiers.md +++ b/docs/content/client-sdk/identifiers.md @@ -50,7 +50,7 @@ Each identifier has its own value, type, verification state, and primary designa **Identifiers are not profiles.** A user profile contains information such as display name and biography, while identifiers represent managed identity-related values. -For general profile management, see [User Management](/docs/client/user-management). +For general profile management, see [User Management](/docs/client-sdk/user-management). ## 2. Accessing the Identifier Client @@ -252,6 +252,38 @@ The server determines whether the new value can be assigned as primary. Adding an identifier does not automatically establish verified ownership. +### Identifier Limits + +The server may restrict how many identifiers a user can maintain. + +By default, UltimateAuth allows up to **10 active identifiers per user**. Applications may configure a different global limit or optional limits for individual identifier types. + +These limits apply to both self-service and administrative identifier operations. + +When adding an identifier, the server validates the configured limits before accepting the operation. + +Possible validation errors include: + +| Error Code | Description | +|---|---| +| `identifier_total_limit_exceeded` | The maximum number of active identifiers has been reached | +| `identifier_type_limit_exceeded` | The maximum number of active identifiers of this type has been reached | + +Applications should display an appropriate message when an addition is rejected. + +For example: + +- **Total limit reached:** "You have reached the maximum number of identifiers allowed for your account." +- **Type limit reached:** "You have reached the maximum number of identifiers allowed for this type." + +Soft-deleted identifiers do not count toward active limits. + +However, deleting an identifier does not necessarily make its value available for reuse. Identifier uniqueness and identifier capacity are separate rules. + +The server remains authoritative when enforcing these limits. Client applications should not rely solely on local identifier counts to determine whether an addition will succeed. + +For configuration details, see [Server Options](/docs/configuration/server-options). + ## 6. Identifier Uniqueness and Normalization UltimateAuth normalizes identifier values and enforces uniqueness within the relevant tenant boundary. @@ -1038,8 +1070,8 @@ Applications should distinguish identifier management from session revocation an ## Next Steps -- [User Management](/docs/client/user-management) β€” Manage user profiles, account status, and lifecycle. -- [Credentials](/docs/client/credentials) β€” Manage passwords, credentials, and recovery workflows. -- [Authentication](/docs/client/authentication) β€” Understand login and authentication flows. +- [User Management](/docs/client-sdk/user-management) β€” Manage user profiles, account status, and lifecycle. +- [Credentials](/docs/client-sdk/credentials) β€” Manage passwords, credentials, and recovery workflows. +- [Authentication](/docs/client-sdk/authentication) β€” Understand login and authentication flows. - [Users](/docs/plugin-domains/users) β€” Explore the user domain architecture. - [Session Security Model](/docs/security/session-security) β€” Understand authentication security boundaries. \ No newline at end of file diff --git a/docs/content/client-sdk/session-management.md b/docs/content/client-sdk/session-management.md index 03ba446c..eb63b166 100644 --- a/docs/content/client-sdk/session-management.md +++ b/docs/content/client-sdk/session-management.md @@ -101,6 +101,7 @@ Each summary provides information suitable for building a device management inte | `LastSeenAt` | `DateTimeOffset?` | Last recorded activity | | `RotationCount` | `int` | Recorded rotation count | | `TouchCount` | `int` | Recorded touch count | +| `SessionCount` | `int` | Total number of session records in the chain, including historical and revoked sessions | | `IsRevoked` | `bool` | Whether the chain is revoked | | `RevokedAt` | `DateTimeOffset?` | Revocation timestamp | | `ActiveSessionId` | `AuthSessionId?` | Associated active session identifier | @@ -438,7 +439,7 @@ var result = await UAuthClient.Flows.LogoutMyDeviceAsync( Pass a `SessionChainId`, not an individual `AuthSessionId`. -For complete logout examples, see [Authentication](/docs/client/authentication). +For complete logout examples, see [Authentication](/docs/client-sdk/authentication). ## 11. Build a Device Management Page @@ -791,7 +792,7 @@ These operations should be protected by appropriate permissions and, where requi ## Next Steps -- [Authentication](/docs/client/authentication) β€” Login, logout, refresh, and authentication state validation. +- [Authentication](/docs/client-sdk/authentication) β€” Login, logout, refresh, and authentication state validation. - [Session Model & Lifecycle](/docs/sessions-devices/session-lifecycle) β€” Session roots, chains, lifecycle, and expiration. - [Device Management](/docs/sessions-devices/device-management) β€” Device-associated authentication contexts. - [Session Security Model](/docs/security/session-security) β€” Revocation and session security boundaries. \ No newline at end of file diff --git a/docs/content/client-sdk/user-management.md b/docs/content/client-sdk/user-management.md index 694fe263..2290b0d0 100644 --- a/docs/content/client-sdk/user-management.md +++ b/docs/content/client-sdk/user-management.md @@ -16,7 +16,7 @@ A user has a stable identity and may have multiple profiles. Account status cont This guide covers the public `IUserClient` API, including self-service and administrative operations, structured results, pagination, multi-profile support, and important update semantics. -For email addresses, usernames, phone numbers, and identifier verification, see [Identifiers](/docs/client/identifiers). +For email addresses, usernames, phone numbers, and identifier verification, see [Identifiers](/docs/client-sdk/identifiers). ## 1. Accessing the User Client @@ -420,7 +420,7 @@ Verification flags must not be treated as client-controlled proof of identifier The server is responsible for enforcing verification requirements and determining whether a caller is authorized to establish verified identifier state. -For identifier-specific operations and verification workflows, see [Identifiers](/docs/client/identifiers). +For identifier-specific operations and verification workflows, see [Identifiers](/docs/client-sdk/identifiers). ### UserCreateResult @@ -1339,8 +1339,8 @@ Applications should consider retention, recovery, audit requirements, and depend ## Next Steps -- [Identifiers](/docs/client/identifiers) β€” Manage usernames, email addresses, phone numbers, and verification. -- [Authentication](/docs/client/authentication) β€” Login, refresh, logout, and authentication state. -- [Session Management](/docs/client/session-management) β€” Inspect and revoke sessions across devices. +- [Identifiers](/docs/client-sdk/identifiers) β€” Manage usernames, email addresses, phone numbers, and verification. +- [Authentication](/docs/client-sdk/authentication) β€” Login, refresh, logout, and authentication state. +- [Session Management](/docs/client-sdk/session-management) β€” Inspect and revoke sessions across devices. - [Users](/docs/plugin-domains/users) β€” Understand the user domain and lifecycle architecture. - [Policies & Access Control](/docs/plugin-domains/policies) β€” Understand authorization enforcement for user operations. \ No newline at end of file diff --git a/docs/content/configuration/client-options.md b/docs/content/configuration/client-options.md index 929b5a64..6ea2e2b0 100644 --- a/docs/content/configuration/client-options.md +++ b/docs/content/configuration/client-options.md @@ -1236,4 +1236,4 @@ The current client registration validates options when they are resolved, rather - **Server Options** β€” Configure authentication security, sessions, tokens, and endpoints. - **Advanced Configuration** β€” Explore effective options, mode resolution, and extension points. - **Client Authentication** β€” Implement login, PKCE, refresh, and logout flows. -- **Client Session Management** β€” Manage authenticated sessions and revocation. \ No newline at end of file +- **Client Session Management** β€” Manage authenticated sessions and revocation. diff --git a/docs/content/configuration/overview.md b/docs/content/configuration/overview.md index 30861406..cbea0478 100644 --- a/docs/content/configuration/overview.md +++ b/docs/content/configuration/overview.md @@ -490,7 +490,7 @@ builder.Services.AddUltimateAuthServer(options => Do not enable an unsupported mode merely because it exists in the enum. -For supported modes and their behavior, see [Authentication Modes](/docs/fundamentals/authentication-modes). +For supported modes and their behavior, see [Authentication Modes](/docs/fundamentals/auth-modes). ## 10. Configure Credential Session Policies @@ -836,7 +836,6 @@ The Core-only registration exists for infrastructure scenarios that explicitly c ## Next Steps - [Configuration Overview](/docs/configuration/configuration-overview) β€” Understand the overall configuration model. -- [Core Options](/docs/configuration/core-options) β€” Explore the reusable option definitions composed into server configuration. - [Server Options](/docs/configuration/server-options) β€” Configure authentication and security behavior. - [Client Options](/docs/configuration/client-options) β€” Configure client profiles, flows, and SDK behavior. - [Advanced Configuration](/docs/configuration/advanced-configuration) β€” Customize integration, multi-tenancy, and runtime extension points. \ No newline at end of file diff --git a/docs/content/configuration/server-options.md b/docs/content/configuration/server-options.md index 62f53aa7..ba10573b 100644 --- a/docs/content/configuration/server-options.md +++ b/docs/content/configuration/server-options.md @@ -14,6 +14,8 @@ UltimateAuth composes its Core-defined options directly into `UAuthServerOptions This guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior. +Built-in HTTP throttling is configured through `options.RateLimiting`. See [Rate Limiting](/docs/security/rate-limiting) for defaults, middleware ownership, proxy configuration and native-policy integration. + ## 1. Register UltimateAuth Server Configure the server through `AddUltimateAuthServer()`: @@ -920,6 +922,69 @@ Identifier normalization and uniqueness are particularly important because they Use the provided options rather than implementing inconsistent normalization independently in the client. +### Identifier Limits + +UltimateAuth supports configurable limits on the number of identifiers associated with each user. + +By default, a user can have up to **10 active identifiers**, regardless of their type. + +```csharp +options.Identifiers.Behavior.MaxIdentifiersPerUser = 10; +``` + +This is a global per-user limit. It applies to the combined number of usernames, email addresses, phone numbers, and custom identifiers. + +#### Optional Per-Type Limits + +Applications can optionally define limits for individual identifier types: + +```csharp +options.Identifiers.Behavior.MaxIdentifiersPerUser = 10; + +options.Identifiers.Behavior.MaxIdentifiersPerType = new() +{ + [UserIdentifierType.Username] = 2, + [UserIdentifierType.Email] = 5, + [UserIdentifierType.Phone] = 3 +}; +``` + +**Per-type limits are not configured by default.** + +When no per-type limit is specified, only the global limit applies. + +For example, with the configuration above, a user may have up to five email addresses, but the combined number of all active identifiers cannot exceed ten. + +#### Default Behavior + +| Option | Default | Description | +|---|---|---| +| `MaxIdentifiersPerUser` | `10` | Maximum total active identifiers per user | +| `MaxIdentifiersPerType` | Empty dictionary | Optional limits for individual identifier types | + +The following rules apply: + +- Primary and non-primary identifiers count equally. +- Soft-deleted identifiers do not count toward active limits. +- Self-service and administrative operations follow the same limits. +- Per-type limits do not override the global limit. +- Existing identifier behavior options remain applicable. + +For example, `AllowMultipleUsernames = false` still prevents multiple usernames, even when the configured username limit is greater than one. + +#### Limit Validation + +When an identifier addition exceeds a configured limit, the operation is rejected with a validation error. + +| Error Code | Meaning | +|---|---| +| `identifier_total_limit_exceeded` | The user has reached the global identifier limit | +| `identifier_type_limit_exceeded` | The user has reached the configured limit for that identifier type | + +Applications should handle these errors and display appropriate feedback. + +Identifier limits are enforced by the Users application layer rather than individual persistence stores. + ## 18. User Profile Options `options.UserProfile` controls profile-related capabilities. diff --git a/docs/content/fundamentals/flow-based-auth.md b/docs/content/fundamentals/flow-based-auth.md index ec1af006..9f8ac553 100644 --- a/docs/content/fundamentals/flow-based-auth.md +++ b/docs/content/fundamentals/flow-based-auth.md @@ -234,7 +234,7 @@ Separating these responsibilities prevents transport behavior from becoming auth For example, whether an access token is delivered through a cookie or header should not change the security requirements used to issue it. -The technical components and request pipeline implementing these responsibilities are documented in [Auth Flows](../auth-flows/). +The technical components and request pipeline implementing these responsibilities are documented in [Auth Flows](/docs/auth-flows/overview). ## Flows across client runtimes diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md index 4e8d16cd..cf851803 100644 --- a/docs/content/getting-started/real-world-setup.md +++ b/docs/content/getting-started/real-world-setup.md @@ -4,7 +4,7 @@ order: 4 group: getting-started --- -# πŸ—οΈ Real-World Setup +# Real-World Setup **One authentication framework. Different deployment models.** @@ -264,11 +264,18 @@ builder.Services.AddUltimateAuthServer(options => options.Login.LockoutDuration = TimeSpan.FromMinutes(15); options.Session.Lifetime = TimeSpan.FromDays(7); options.Identifiers.Behavior.AllowMultipleUsernames = true; + options.Password.Level = PasswordPolicyLevel.Strict; }); ``` The effective authentication mode is resolved at runtime. The application does not need to select a single mode globally. +### Password Security Configuration + +UltimateAuth uses the Standard password policy by default. Applications can select Basic or Strict, or override individual requirements through `options.Password`. + +For the preset comparison, configuration examples, and password-history settings, see [Password Policy & History](/docs/security/password-policy). + ## 4. UAuthHub β€” Dedicated Authentication Server **Best for:** Applications where multiple clients share one authentication authority. @@ -1008,7 +1015,45 @@ Remember that effective mode defaults and mode-specific overrides are applied af For detailed configuration behavior, see Server Options and Advanced Configuration. -## 12. Development and Production Differences +## 12. Client IP Address & Reverse Proxy + +UltimateAuth uses ASP.NET Core's resolved client IP address for device information and IP-based rate limiting. It does not directly trust `X-Forwarded-For` or `CF-Connecting-IP` headers. + +If your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy. + +In `Program.cs`, before `builder.Build()`: + +```csharp +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; + + // Replace with your actual Caddy or Nginx proxy IP. + options.KnownProxies.Add(IPAddress.Parse("172.20.0.5")); +}); +``` + +Then enable the middleware **before** UltimateAuth and HTTPS redirection: + +```csharp +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); +app.UseUltimateAuthWithAspNetCore(); +``` + +The example IP `172.20.0.5` represents a trusted reverse proxy, not the visitor's IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network. + +**Important:** Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required. + +For multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure. + +## 13. Development and Production Differences The samples intentionally prioritize visibility and repeatability. @@ -1049,7 +1094,7 @@ Configure persistent signing keys for authentication modes that issue JWTs. Development signing key providers are not a substitute for production key management. -## 13. Deployment Checklist +## 14. Deployment Checklist Before deploying, verify the following: @@ -1064,9 +1109,10 @@ Before deploying, verify the following: - [ ] Client and server endpoint URLs match the deployment. - [ ] Session expiration, refresh, logout, and revocation have been tested. - [ ] Protected resources enforce server-side authorization. +- [ ] Reverse proxy headers are configured correctly if applicable. - [ ] Authentication state and persistence survive the expected application lifecycle. -## 14. Choosing Your Architecture +## 15. Choosing Your Architecture **Start with one server and one client. Connect more clients whenever you need them.** diff --git a/docs/content/migration/index.md b/docs/content/migration/index.md index 0cd8f9d0..f890b36a 100644 --- a/docs/content/migration/index.md +++ b/docs/content/migration/index.md @@ -6,10 +6,84 @@ group: migration # Migration Guides -Version-specific instructions for upgrading UltimateAuth applications. +Keep your UltimateAuth applications up to date with clear, version-specific upgrade guidance. -Migration guides focus on breaking changes, behavior changes, and required application updates. +This page provides an overview of stable UltimateAuth releases, their migration impact, and any actions required when upgrading. -## Available Guides +**Most updates require little or no application code changes.** When an update requires attention, a dedicated migration guide explains what changed and how to adapt your application. -- [Migrating to v0.1.1](/docs/migration/v0.1.1) β€” Blazor route authorization and component lifecycle changes. +## Version Migration Overview + +The table below lists stable releases in descending order, with the newest version first. + +Preview and release candidate versions are excluded. + +| Version | Migration Impact | What's Changed | Migration Guide | +|---|---|---|---| +| **v0.2.0 (In Progress)** | **Medium (Provisional)** | Async authorization APIs, authorization checks, and integration changes | [View Guide](/docs/migration/v0.2.0) | +| **v0.1.4** | **Low** | CSRF protection, antiforgery integration, and interface changes | [View Guide](/docs/migration/v0.1.4) | +| **v0.1.3** | **None** | Session deletion, revocation, and refresh consistency fixes | Not required | +| **v0.1.2** | **None** | Password change transaction and session revocation fixes | Not required | +| **v0.1.1** | **Low** | Blazor route authorization and component lifecycle changes | [View Guide](/docs/migration/v0.1.1) | +| **v0.1.0** | **N/A** | Initial stable release | Not applicable | + +**Migration impact describes the expected developer effort and affected integration surface, not the size of the code change or the significance of the release.** A small change to a widely used authentication API may have a higher migration impact than a large internal refactor. + +> Individual preview and release candidate versions are not listed separately. Migration guidance for an in-progress release may change before its stable release. + +Security and reliability fixes may have an impact of **None** while still being strongly recommended. + +## Understanding Migration Impact + +| Impact | Meaning | Expected Action | +|---|---|---| +| **None** | No application code or configuration changes are required for standard integrations. | Update packages and run existing tests. | +| **Low** | Small, localized changes may be required, typically for specific integrations or customizations. | Review the migration guide and update affected code. | +| **Medium** | Changes affect commonly used APIs, core integration points, or multiple application areas. Even a small code change is classified as Medium when developers must update functionality they use directly and extensively. | Review the migration guide, update affected integrations, and perform targeted regression testing. | +| **High** | Significant architectural, API, persistence, or behavioral changes require a planned migration. | Review all migration steps, test thoroughly, and plan deployment carefully. | + +Migration impact is evaluated using both **the effort required and how directly the changes affect application developers**. + +A change does not qualify as Low simply because it requires only a few lines of code. Breaking changes to frequently used authentication, authorization, session management, or client integration APIs may be classified as Medium even when the implementation changes are relatively small. + +Applications using only unaffected functionality may require fewer changes than the stated impact level suggests. + +## How to Upgrade + +1. **Identify your current version.** Check the installed `CodeBeam.UltimateAuth.*` NuGet packages. +2. **Review every version between your current and target versions.** Migration requirements are cumulative when skipping releases. +3. **Follow the linked migration guides.** Apply any required API, configuration, or behavioral changes in version order. +4. **Update your UltimateAuth packages together.** Keep related packages on compatible versions. +5. **Build and test your application.** Verify login, logout, refresh, authorization, and session management flows relevant to your integration. + +For releases marked **None**, no dedicated migration guide is necessary. You should still review the release notes and run your application tests. + +## Available Migration Guides + +### [Migrating to v0.1.4](/docs/migration/v0.1.4) + +**Impact: Low** + +Introduces antiforgery protection for cookie-based authentication flows, updates browser client integration, and changes selected session resolution and browser bridge interfaces. + +Applications using the standard UltimateAuth browser client generally receive antiforgery handling automatically. + +Custom HTTP clients, manually implemented browser bridges, and custom session resolvers should review the guide. + +### [Migrating to v0.1.1](/docs/migration/v0.1.1) + +**Impact: Low** + +Introduces routing-level enforcement for `[UAuthAuthorize]` and updates the `UAuthComponentBase` lifecycle. + +Most applications using the built-in `UAuthApp` router require no changes. Applications with custom routers, component lifecycle overrides, or specialized session requirements should review the guide. + +## Release History + +For complete release notes, bug fixes, and security updates, visit the [UltimateAuth GitHub Releases](https://github.com/CodeBeamOrg/UltimateAuth/releases) page. + +Migration guides focus on changes developers must make to their applications. Release notes provide the complete history of changes in each version. + +--- + +**Upgrading across multiple versions?** Start with the earliest version after your currently installed release and review each migration entry through your target version. \ No newline at end of file diff --git a/docs/content/migration/v0.1.4.md b/docs/content/migration/v0.1.4.md new file mode 100644 index 00000000..c197caea --- /dev/null +++ b/docs/content/migration/v0.1.4.md @@ -0,0 +1,194 @@ +ο»Ώ--- +title: Migrating to v0.1.4 +order: 3 +group: migration +--- + +# Migrating to v0.1.4 + +**Migration Impact: Low** + +UltimateAuth v0.1.4 introduces antiforgery protection for cookie-based authentication flows and updates several client and server integration interfaces. + +The release strengthens protection against Cross-Site Request Forgery (CSRF) while preserving the standard UltimateAuth developer experience. + +**Most applications using the built-in UltimateAuth browser client require no application code changes.** Antiforgery token handling is integrated into the client. + +Applications using custom HTTP requests, custom browser bridges, or custom session resolvers should review the changes below. + +## 1. Antiforgery Protection for Cookie-Based Authentication + +UltimateAuth now validates antiforgery tokens for protected operations when cookie-based authentication is applicable. + +This includes relevant authentication operations and authenticated state-changing endpoints. + +Requests that fail antiforgery validation are rejected with HTTP `400 Bad Request` and the following error: + +```json +{ + "error": "invalid_antiforgery_token" +} +``` + +### Using the built-in browser client + +The UltimateAuth browser client now supports retrieving and submitting antiforgery tokens automatically. + +If your application uses the standard UltimateAuth client integration, no additional antiforgery implementation is normally required. + +Make sure the server and client packages are updated together. + +### Using custom HTTP requests + +If your application calls protected UltimateAuth endpoints directly from a browser, you must obtain an antiforgery token and submit it with the request. + +The default token endpoint is: + +```http +GET /auth/antiforgery/token +``` + +The response contains: + +```json +{ + "requestToken": "...", + "headerName": "X-UAuth-CSRF", + "formFieldName": "__RequestVerificationToken" +} +``` + +The endpoint also issues an antiforgery cookie. + +For subsequent protected requests: + +1. Obtain a token from the configured antiforgery endpoint. +2. Preserve the associated antiforgery cookie. +3. Send the request token using the returned `headerName`. +4. Include the appropriate authentication credentials for the request. + +For example: + +```http +POST /auth/logout +X-UAuth-CSRF: +Cookie: +``` + +The request token and antiforgery cookie must belong to the same antiforgery context. + +**Do not hardcode or fabricate antiforgery tokens.** Always obtain them from the server. + +The default endpoint path assumes the standard `/auth` base path. Applications using custom routing or multi-tenancy must use their configured endpoint URL. + +### Custom server-side integrations + +Applications using custom HTTP clients or server-to-server integrations should review whether their requests use cookie-based authentication. + +Antiforgery requirements depend on the authentication transport and operation. They are not automatically equivalent for every bearer-token or header-based request. + +Do not disable antiforgery validation to resolve an integration failure. Update the affected request flow instead. + +## 2. ISessionIdResolver Is Now Asynchronous + +The `ISessionIdResolver` interface has changed to support asynchronous session resolution. + +If you implement this interface yourself, update the implementation. + +**Before (v0.1.3):** + +```csharp +public AuthSessionId? Resolve(HttpContext context) +{ + // Resolve the session identifier. +} +``` + +**After (v0.1.4):** + +```csharp +public ValueTask ResolveAsync(HttpContext context, CancellationToken ct = default) +{ + ct.ThrowIfCancellationRequested(); + + // Resolve the session identifier. + return ValueTask.FromResult(null); +} +``` + +Replace the placeholder return with your existing resolution logic. + +If your resolver performs asynchronous operations, use an `async ValueTask` implementation and await those operations. + +Update any direct calls from `Resolve(...)` to `ResolveAsync(...)` and await the result. + +Applications using the built-in session resolver do not need to make this change. + +## 3. Custom Browser Bridges + +The `IBrowserUAuthBridge` interface now includes: + +```csharp +ValueTask ConfigureAntiforgeryAsync(string tokenUrl, string? trustedOrigin = null); +``` + +This allows the UltimateAuth client bootstrapper to configure antiforgery handling for browser requests. + +If you provide a custom implementation of `IBrowserUAuthBridge`, implement this new method. + +Your implementation must configure the browser-side antiforgery integration using the supplied token endpoint and optional trusted origin. + +The exact implementation depends on your browser integration. + +**Do not add an empty implementation unless antiforgery handling is already provided by your custom bridge.** Doing so may leave protected browser requests without the required token. + +Applications using the built-in UltimateAuth browser bridge do not need to make this change. + +## 4. Antiforgery Endpoint Configuration + +v0.1.4 introduces a server-side antiforgery configuration section: + +```csharp +options.Antiforgery.TokenEndpoint = "/antiforgery/token"; +``` + +The client also exposes the corresponding endpoint setting: + +```csharp +options.Endpoints.AntiforgeryToken = "/antiforgery/token"; +``` + +The default endpoint path is `/antiforgery/token`, resolved under the configured UltimateAuth base path. + +No configuration change is required when using the defaults. + +If you customize the endpoint, ensure the server and client configurations match. + +## 5. Verify Your Application + +After upgrading to v0.1.4, verify the following flows where applicable: + +- Login and logout using the built-in browser client. +- Session refresh and validation. +- Authenticated account and session management operations. +- Custom requests to UltimateAuth endpoints. +- Custom session resolver implementations. +- Custom browser bridge implementations. +- External Hub and multi-tenant configurations. + +For applications using custom browser requests, verify that valid antiforgery tokens allow protected operations and missing or invalid tokens are rejected. + +## Migration Checklist + +- [ ] Update UltimateAuth packages to compatible v0.1.4 versions. +- [ ] Update custom `ISessionIdResolver` implementations, if any. +- [ ] Update custom `IBrowserUAuthBridge` implementations, if any. +- [ ] Add antiforgery token handling to custom browser HTTP requests, if applicable. +- [ ] Verify custom antiforgery endpoint configuration, if applicable. +- [ ] Run authentication, authorization, and session regression tests. + +--- + +**Using the standard UltimateAuth client and built-in session resolution?** No application code changes are normally required. Update your packages and verify your existing authentication flows. + +For the complete list of changes, see [UltimateAuth v0.1.4 Release](https://github.com/CodeBeamOrg/UltimateAuth/releases/tag/v0.1.4). diff --git a/docs/content/migration/v0.2.0.md b/docs/content/migration/v0.2.0.md new file mode 100644 index 00000000..08c132d9 --- /dev/null +++ b/docs/content/migration/v0.2.0.md @@ -0,0 +1,159 @@ +ο»Ώ--- +title: Migrating to v0.2.0 +order: 4 +group: migration +--- + +# Migrating to v0.2.0 + +**Migration Impact: Medium β€” In Progress** + +**Latest published prerelease reviewed: v0.2.0-preview.2** + +This guide covers migration requirements for the upcoming UltimateAuth v0.2.0 release. + +The guide is provisional and will be updated as new preview and release candidate versions become available. The final migration requirements may change before the stable v0.2.0 release. + +## 1. Asynchronous Authorization Policies + +**Available since preview.1** + +Custom implementations of `IAccessPolicy`, `IAccessInvariant`, and `IAccessAuthority` should migrate to `DecideAsync`. + +**Before:** + +```csharp +public AccessDecision Decide(AccessContext context) +{ + return AccessDecision.Allow(); +} +``` + +**After:** + +```csharp +public ValueTask DecideAsync(AccessContext context, CancellationToken ct = default) +{ + return ValueTask.FromResult(AccessDecision.Allow()); +} +``` + +For `IAccessAuthority`, preserve the existing `runtimePolicies` parameter and add the cancellation token. + +Synchronous `Decide` methods are obsolete. Existing synchronous implementations retain compatibility through the interface defaults, but new implementations should use `DecideAsync`. + +If your application invokes an authority directly, switch to `DecideAsync` and await the result. + +## 2. Authorization Check Results + +**Available since preview.1** + +`CheckAsync` now returns an explicit authorization decision in the response body. + +If your application previously interpreted HTTP `403` as the result of a successful authorization evaluation that denied access, update that handling. + +```csharp +var result = await UAuthClient.Authorization.CheckAsync(request); + +if (result.IsSuccess && result.Value is { IsAllowed: true }) +{ + // Authorized +} +``` + +An HTTP-successful authorization check does not necessarily mean that access was granted. + +Inspect the returned authorization decision rather than relying solely on the HTTP status. + +## 3. Custom User Lifecycle Integrations + +**Available since preview.2** + +The `IUserLifecycleIntegration.OnUserCreatedAsync` signature now includes `UAuthClientProfile`. + +**Before:** + +```csharp +Task OnUserCreatedAsync( + TenantKey tenant, + UserKey userKey, + object request, + CancellationToken ct = default); +``` + +**After:** + +```csharp +Task OnUserCreatedAsync( + TenantKey tenant, + UserKey userKey, + object request, + UAuthClientProfile clientProfile, + CancellationToken ct = default); +``` + +Update custom implementations to accept the additional client profile parameter. + +Applications using built-in lifecycle integrations do not need to make this change. + +## 4. Identifier and Profile Limits + +**Available since preview.2** + +The reference Users domain now enforces additional identifier and profile safety limits. + +The default maximum is 10 active identifiers per user. Applications requiring a different capacity should configure `Identifiers.Behavior.MaxIdentifiersPerUser`. + +Existing profile creation and update operations must also respect the new field-length and metadata limits. + +If you use EF Core persistence: + +1. Review existing profile data for values exceeding the new limits. +2. Add or apply the appropriate EF Core database migration for your application's DbContext. +3. Verify that applications handling profile validation errors use the returned error codes. + +See [Profile Validation & Limits](/docs/plugin-domains/profile-validation-limits) for the supported limits. + +## 5. Password Policies and History + +**Available since preview.2** + +The reference password credential implementation now applies configurable password policies, with Standard as the default level. + +Review custom registration, password-change, and password-reset flows to ensure that they handle server-side password validation errors. + +Applications using persistent password-history storage must apply the required database migration before enabling password history. + +Password history is disabled by default (`RememberLast = 0`). + +See [Password Policy & History](/docs/security/password-policy) for configuration and persistence requirements. + +## 6. Rate Limiting + +**Available since preview.2** + +Built-in endpoint rate limiting is enabled by default when using: + +```csharp +app.UseUltimateAuthWithAspNetCore(); +``` + +If your application already registers its own rate limiting middleware, avoid installing a duplicate limiter. + +To retain application-owned rate limiting: + +```csharp +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); +``` + +Then configure your existing `UseRateLimiter()` middleware in the appropriate pipeline position. + +Applications using `UseUltimateAuth()` without the integrated ASP.NET Core helper do not enable UltimateAuth rate limiting by default. + +Review custom HTTP clients and authentication flows for HTTP `429 Too Many Requests` handling. + +See [Rate Limiting](/docs/security/rate-limiting) for middleware integration and defaults. + +--- + +This page will continue to evolve through the v0.2.0 preview and RC series and will become the stable migration guide when v0.2.0 is released. diff --git a/docs/content/plugin-domains/authorization.md b/docs/content/plugin-domains/authorization.md index f5369dbb..8cb2dc89 100644 --- a/docs/content/plugin-domains/authorization.md +++ b/docs/content/plugin-domains/authorization.md @@ -1,6 +1,6 @@ --- title: Authorization -order: 4 +order: 5 group: plugin-domains --- diff --git a/docs/content/plugin-domains/credentials.md b/docs/content/plugin-domains/credentials.md index c9ad7f59..67b7bf89 100644 --- a/docs/content/plugin-domains/credentials.md +++ b/docs/content/plugin-domains/credentials.md @@ -1,6 +1,6 @@ --- title: Credentials -order: 3 +order: 4 group: plugin-domains --- @@ -115,6 +115,10 @@ Secret verification and credential usability are related but distinct decisions. A correctly supplied password must not make a revoked or otherwise unusable credential valid. +Password strength, common-password detection, user similarity, and password reuse are governed by configurable security policies. + +See [Password Policy & History](/docs/security/password-policy) for the built-in policy levels, defaults, and configuration options. + ## Credential Validation UltimateAuth separates credential-specific verification from the broader authentication flow. @@ -302,6 +306,6 @@ The current password implementation provides the operational foundation. The bro - [Users](/docs/plugin-domains/users) β€” Understand identity ownership, identifiers, and account lifecycle. - [Authorization](/docs/plugin-domains/authorization) β€” Explore how authenticated identities are evaluated against access policies. -- [Plugin Domains](/docs/plugin-domains) β€” Review the architecture and responsibilities of Plugin Domains. +- [Plugin Domains](/docs/plugin-domains/overview) β€” Review the architecture and responsibilities of Plugin Domains. -For the authentication flow that consumes credential verification results, see [Flow-Based Authentication](/docs/fundamentals/flow-based-authentication). \ No newline at end of file +For the authentication flow that consumes credential verification results, see [Flow-Based Authentication](/docs/fundamentals/flow-based-auth). \ No newline at end of file diff --git a/docs/content/plugin-domains/policies.md b/docs/content/plugin-domains/policies.md index 63c660aa..47ceff9c 100644 --- a/docs/content/plugin-domains/policies.md +++ b/docs/content/plugin-domains/policies.md @@ -1,6 +1,6 @@ --- title: Policies & Access Control -order: 5 +order: 6 group: plugin-domains --- diff --git a/docs/content/plugin-domains/profile-validation-limits.md b/docs/content/plugin-domains/profile-validation-limits.md new file mode 100644 index 00000000..edaa3b70 --- /dev/null +++ b/docs/content/plugin-domains/profile-validation-limits.md @@ -0,0 +1,110 @@ +ο»Ώ--- +title: Profile Validation & Limits +order: 3 +group: plugin-domains +--- + +# Profile Validation & Limits + +UltimateAuth enforces framework-level safety limits on standard user profile fields and metadata. + +These limits protect applications from excessively large profile values, unnecessary storage consumption, and resource exhaustion. + +They apply consistently to the reference Users domain, regardless of whether InMemory or Entity Framework Core persistence is used. + +## Profile Field Limits + +The following maximum lengths are enforced: + +| Field | Maximum characters | +|---|---:| +| FirstName | 128 | +| LastName | 128 | +| DisplayName | 256 | +| Gender | 128 | +| Bio | 4,096 | +| Language | 64 | +| TimeZone | 128 | +| Culture | 64 | + +All fields are optional unless an application introduces additional requirements. + +Limits are defined centrally in `UserProfileLimits` and cannot be increased through application configuration. + +## Metadata Limits + +User profiles support optional key-value metadata. + +To prevent unbounded metadata storage, UltimateAuth enforces the following limits: + +| Constraint | Maximum | +|---|---:| +| Metadata entries | 32 | +| Key length | 128 characters | +| Value length | 2,048 characters | +| Total metadata size | 8,192 UTF-8 bytes | + +The total size includes metadata keys and values. + +These limits apply to the complete metadata collection, not individually to each entry. + +## Validation Enforcement + +Profile safety limits are enforced during: + +- User creation. +- Additional profile creation. +- Profile updates. +- Profile cloning. + +Validation occurs before invalid data is persisted. + +Framework-level safety validation is independent of `IUserProfileValidator`, ensuring that replacing the application validator cannot bypass these limits. + +The same restrictions apply to both self-service and administrative operations. + +## Custom Validation + +Applications can implement `IUserProfileValidator` to introduce additional validation rules. + +For example, an application may require a display name, restrict accepted languages, or enforce a shorter biography. + +Custom validation can be stricter than the framework limits but cannot override the built-in safety boundaries. + +The recommended approach is to use `IUserProfileValidator` for application-specific rules while relying on UltimateAuth for baseline safety validation. + +## Validation Errors + +When a profile exceeds the supported limits, UltimateAuth rejects the operation with a validation error. + +Common error codes include: + +| Error code | Meaning | +|---|---| +| `profile_field_too_long` | A standard profile field exceeds its maximum length. | +| `profile_metadata_entries_exceeded` | The metadata collection contains too many entries. | +| `profile_metadata_key_too_long` | A metadata key exceeds its maximum length. | +| `profile_metadata_value_too_long` | A metadata value exceeds its maximum length. | +| `profile_metadata_too_large` | The combined metadata size exceeds the allowed limit. | + +Applications should use the error `Code` rather than relying on exception message text. + +## Persistence Considerations + +The reference Entity Framework Core implementation configures profile column lengths using the same framework limits. + +Existing databases may require a migration when upgrading to a version that introduces these constraints. + +Before applying restrictive database changes, review existing profile records for values exceeding the new limits. + +The InMemory implementation uses the same application-level validation rules. + +## Security Considerations + +Profile limits are a defense-in-depth measure. + +They do not replace HTTP request size limits, rate limiting, or application-specific validation. + +Applications should configure these additional protections according to their deployment requirements. + +For an overview of user identity and profile management, see [Users Domain](/docs/plugin-domains/users). \ No newline at end of file diff --git a/docs/content/plugin-domains/users.md b/docs/content/plugin-domains/users.md index 3b5ffdbe..b6fb6ef0 100644 --- a/docs/content/plugin-domains/users.md +++ b/docs/content/plugin-domains/users.md @@ -113,6 +113,26 @@ UltimateAuth treats identifiers as independent identity records rather than assu This supports applications with different identity requirements without changing the underlying authentication model. +### Identifier Capacity and Limits + +UltimateAuth allows users to maintain multiple identifiers while providing configurable limits to prevent uncontrolled identifier growth. + +The default configuration allows **10 active identifiers per user**, shared across all identifier types. + +Applications can optionally configure additional per-type limits when their identity model requires them. + +For example, an application may allow several email addresses and phone numbers while restricting the number of usernames. + +Identifier limits are independent of identifier uniqueness, ownership verification, and primary designation. + +A primary identifier consumes the same capacity as a non-primary identifier. Soft-deleted identifiers do not consume active capacity, although their normalized values may remain reserved under the configured uniqueness rules. + +Limits apply consistently to self-service and administrative identifier additions. + +These rules belong to the Users application layer, keeping persistence implementations independent of application-specific identifier policies. + +See [Server Options](/docs/configuration/server-options) for configuration examples. + ### Identifier Properties The reference identifier model distinguishes several important properties: diff --git a/docs/content/security/password-policy.md b/docs/content/security/password-policy.md new file mode 100644 index 00000000..7f42edc6 --- /dev/null +++ b/docs/content/security/password-policy.md @@ -0,0 +1,253 @@ +ο»Ώ--- +title: Password Policy & History +order: 6 +group: security +--- + +# Password Policy & History + +UltimateAuth provides configurable password security policies for applications using its reference password credential implementation. + +Instead of requiring every application to implement password validation independently, UltimateAuth provides three built-in policy levels: **Basic**, **Standard**, and **Strict**. + +Each level defines a starting set of password requirements. Developers can override individual requirements without replacing the entire policy. + +Password validation is enforced on the server. Client applications can retrieve the effective policy to display requirements and provide immediate feedback. + +## Password Policy Levels + +UltimateAuth includes three built-in presets. + +**Standard is the default policy level.** + +| Requirement | Basic | Standard (Default) | Strict | +|---|---|---|---| +| Minimum length | 8 | 8 | 15 | +| Maximum length | 128 | 128 | 128 | +| Require letter | No | Yes | Yes | +| Require digit | No | Yes | Yes | +| Require uppercase | No | No | Yes | +| Require lowercase | No | No | Yes | +| Require special character | No | No | Yes | +| Common-password detection | Basic | Standard | Strict | +| User-similarity detection | Basic | Standard | Strict | +| Previous passwords remembered | 0 | 0 | 0 | + +These values are the built-in defaults, not fixed limits for every application. + +### Basic + +Basic provides a minimum password length without requiring particular character classes. + +It still enables basic common-password and user-similarity checks. + +This preset is intended for applications that prefer fewer composition requirements while retaining server-side checks against predictable passwords. + +### Standard + +Standard is the default UltimateAuth password policy. + +It requires at least eight characters, including a letter and a digit, and enables Standard-level common-password and user-similarity checks. + +Applications do not need to explicitly select Standard unless they want to make their configuration intentional. + +### Strict + +Strict increases the minimum length to 15 characters and requires letters, digits, uppercase letters, lowercase letters, and special characters. + +It also selects Strict-level common-password and user-similarity checks. + +Strict is available for applications that require more restrictive password composition rules. It is not automatically the best policy for every application: usability, password-manager compatibility, account recovery, and the application's threat model should also be considered. + +## Configuring Password Policies + +Password policies are configured through `UAuthServerOptions.Password`. + +### Use a built-in preset + +In `Program.cs`: + +```csharp +using CodeBeam.UltimateAuth.Credentials.Contracts; + +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Strict; +}); +``` + +This applies the Strict preset, including its default length, character requirements, and optional security checks. + +### Customize individual requirements + +You can override any supported requirement without creating a custom policy implementation. + +```csharp +using CodeBeam.UltimateAuth.Credentials.Contracts; + +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Standard; + + options.Password.MinLength = 12; + options.Password.MaxLength = 64; + + options.Password.RequireUppercase = true; + options.Password.RequireSpecialCharacter = true; +}); +``` + +The effective policy starts with Standard and applies the explicitly configured overrides. + +Unspecified requirements retain their preset values. + +For example, this configuration still requires a letter and a digit, even though those properties are not explicitly set. + +### Disable an individual security check + +Common-password detection and user-similarity detection are configured independently from the main preset. + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.Level = PasswordPolicyLevel.Standard; + + options.Password.CommonPasswords = + PasswordPolicyLevel.Strict; + + options.Password.UserSimilarity = + PasswordPolicyLevel.Disabled; +}); +``` + +This keeps Standard password composition rules, increases common-password detection to Strict, and disables user-similarity detection. + +Disabling security checks should be a deliberate application decision. + +## Password Length Limits + +UltimateAuth defines the following limits: + +| Setting | Value | +|---|---| +| Minimum configurable password length | 6 | +| Default maximum password length | 128 | +| Absolute input length limit | 256 | + +The built-in presets use a maximum length of 128 characters. + +Applications can customize the configured maximum within the supported limits. The absolute limit is a defensive boundary and is not intended to replace application-level password policy configuration. + +Invalid password policy options are rejected during server options validation. + +## Common-Password Detection + +A password may satisfy every character requirement and still be easy to guess. + +For example, a password such as `qwerty123` may satisfy Standard's letter, digit, and length requirements but still be rejected by the server's common-password checker. + +UltimateAuth supports independently configurable detection levels: + +- `Disabled` +- `Basic` +- `Standard` +- `Strict` + +The selected level determines the checker configuration used by the reference implementation. + +Common-password detection is a server-side operation. A client-side character validator cannot establish whether a password passes this check. + +## User-Similarity Detection + +Passwords that closely resemble account identifiers or other available user information may be predictable. + +UltimateAuth provides user-similarity checking with the same configurable levels: + +- `Disabled` +- `Basic` +- `Standard` +- `Strict` + +The server evaluates similarity using the available password-policy context. + +Client applications should not attempt to reproduce the complete similarity decision from public policy information alone. + +## Password History + +Password history helps prevent users from repeatedly selecting passwords they have used before. + +UltimateAuth can remember a configurable number of previous passwords and reject reuse during applicable password credential operations. + +### Enable password history + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.Password.History.RememberLast = 5; +}); +``` + +`RememberLast` controls how many previous passwords are checked. + +| Value | Behavior | +|---|---| +| `0` | Previous-password history checking is disabled | +| `1–10` | Check up to the configured number of previous passwords | + +The default value is `0`. + +The reference password-history validator also checks whether the submitted password matches the current credential, independently of the configured previous-password count. + +Password history compares password candidates against protected password representations; it does not require retaining previous passwords in plaintext. + +### Persistence + +Password history requires an appropriate history store. + +UltimateAuth provides reference storage implementations for in-memory and Entity Framework Core persistence. + +For persistent deployments, apply the required database migrations before enabling password-history functionality. + +An in-memory store is useful for development and testing but does not provide durable history across application restarts. + +## Server Enforcement and Client Feedback + +UltimateAuth distinguishes between authoritative password validation and client-side guidance. + +**The server is the security boundary.** + +Client applications may retrieve the effective policy and validate its publicly expressible requirements, including: + +- Minimum and maximum length +- Letter and digit requirements +- Uppercase and lowercase requirements +- Special-character requirements + +Other checks remain server-authoritative: + +- Common-password detection +- User-similarity detection +- Password history + +A password passing client-side validation is not guaranteed to be accepted by the server. + +Applications should handle server-side password-policy violations as expected validation errors, rather than displaying them as unexpected application failures. + +For client API usage and examples, see [Client SDK β€” Credentials](/docs/client/credentials). + +## Security Considerations + +Password policies are one part of credential security, not a replacement for the broader authentication system. + +Applications should also consider secure password hashing, transport security, login throttling, account recovery, session revocation, and appropriate authorization for credential-management operations. + +Changing a password may also affect existing authentication sessions, depending on the configured credential session policy. + +See [Session Security Model](/docs/security/session-security) for the relevant session security boundaries. + +## Next Steps + +- [Credentials Domain](/docs/plugin-domains/credentials) β€” Understand credential ownership, validation, and lifecycle. +- [Client SDK β€” Credentials](/docs/client/credentials) β€” Retrieve password requirements and implement credential-management flows. +- [Real-World Setup](/docs/getting-started/real-world-setup) β€” Configure UltimateAuth in a real application. +- [Session Security Model](/docs/security/session-security) β€” Understand the security consequences of credential changes. diff --git a/docs/content/security/rate-limiting.md b/docs/content/security/rate-limiting.md new file mode 100644 index 00000000..6b1301b9 --- /dev/null +++ b/docs/content/security/rate-limiting.md @@ -0,0 +1,638 @@ +--- +title: Rate Limiting +order: 8 +group: security +--- + +# Rate Limiting + +UltimateAuth provides built-in HTTP endpoint rate limiting using ASP.NET Core's native rate limiting infrastructure, together with client-side handling of HTTP 429 responses. + +Rate limiting helps reduce excessive authentication requests, credential attacks, recovery abuse, and unnecessary session traffic without introducing a separate persistence dependency. + +The server enforces request limits. UltimateAuth clients recognize throttled responses, preserve valid authentication state, and defer selected background operations when appropriate. + +No additional rate limiting package is required. + +## Getting started + +Rate limiting is enabled by default when using the integrated ASP.NET Core middleware helper. + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Standard.PermitLimit = 240; + options.RateLimiting.Login.PermitLimit = 40; + + options.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() + { + PermitLimit = 1200, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +Configure the middleware pipeline: + +```csharp +app.UseForwardedHeaders(); +app.UseRouting(); + +app.UseUltimateAuthWithAspNetCore(); + +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapUAuthHub(); +``` + +Import `CodeBeam.UltimateAuth.Server.RateLimiting` for rate limiting rules and endpoint identifiers. + +Configure forwarded headers only when appropriate for the deployment, using explicitly trusted proxies and networks. + +Rate limiting options are also available through the `UltimateAuth:Server:RateLimiting` configuration section. The existing server options binding behavior applies, including configuration-file precedence. + +Rate limiting settings are configured at startup. They are not automatically scoped to individual clients or authentication modes. + +## Middleware integration + +The integrated helper exposes a single method with two optional parameters: + +```csharp +public static IApplicationBuilder UseUltimateAuthWithAspNetCore( + this IApplicationBuilder app, + bool? enableCors = null, + bool useRateLimiting = true) +``` + +The default middleware order is: + +1. CORS, when enabled or required by UAuthHub +2. UltimateAuth context middleware +3. ASP.NET Core authentication +4. ASP.NET Core rate limiting +5. ASP.NET Core authorization + +This places the rate limiter after authentication, allowing authenticated endpoint policies to use validated UltimateAuth actor information. + +### Configuration examples + +```csharp +// Automatic CORS selection; rate limiting enabled +app.UseUltimateAuthWithAspNetCore(); + +// Explicit CORS; rate limiting enabled +app.UseUltimateAuthWithAspNetCore(enableCors: true); + +// Automatic CORS; rate limiting disabled +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); + +// Explicit configuration of both options +app.UseUltimateAuthWithAspNetCore(enableCors: true, useRateLimiting: true); +``` + +`enableCors = null` automatically enables CORS when required by UAuthHub. Setting it to `false` does not remove the host's responsibility to configure CORS when needed. + +The standalone UltimateAuth middleware helper does not install ASP.NET Core authentication. If authentication has not already established a validated UltimateAuth actor, rate limiting partitioning falls back to the connection IP. + +### Existing application rate limiters + +If the application already installs its own `UseRateLimiter()` middleware, disable the helper-owned installation: + +```csharp +app.UseRouting(); +app.UseAuthentication(); + +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); + +app.UseRateLimiter(); +``` + +This illustrates middleware ownership rather than a universal pipeline layout. Hosts must preserve the required routing, authentication, CORS, and authorization order and avoid duplicate middleware registration. + +UltimateAuth cannot detect a separately installed native rate limiting middleware instance. + +Repeated calls to the UltimateAuth helper install at most one helper-owned rate limiter on the same application builder. Other middleware installed by the helper is not deduplicated. + +An application builder created with `ApplicationBuilder.New()` has its own middleware installation lifecycle. + +Disabling UltimateAuth's built-in endpoint policies does not disable an application-owned global limiter or native application policies. + +## Rules and default limits + +UltimateAuth uses native sliding-window limiters with six segments and no request queue. + +The default rule groups are: + +| Rule | Default | Typical operations | +|---|---|---| +| Sensitive | 30 requests/minute | Sensitive credential and recovery operations | +| Standard | 120 requests/minute | Authenticated management operations | +| Frequent | 600 requests/minute | Validation, refresh, logout, authorization checks | +| Login | 20 requests/minute/IP | Shared credential-verification budget | + +These are starting defaults, not recommended universal production limits. Adjust them based on application traffic, expected concurrency, and deployment conditions. + +Group rules act as templates for separate endpoint budgets. For example, validation and refresh do not share a counter merely because both use the Frequent group. + +### Shared login budget + +The Login rule is shared by: + +- Login +- TryLogin +- PkceComplete +- PkceTryComplete + +These credential-verification operations share the same budget for a partition. + +Configure the shared limit through: + +```csharp +options.RateLimiting.Login.PermitLimit = 40; +``` + +An explicit endpoint override replaces that endpoint's rule and separates it from the shared login budget. + +### Additional sensitive defaults + +Certain operations have stricter defaults than the Sensitive group: + +| Operation | Default | +|---|---| +| Anonymous registration | 10 requests/10 minutes/IP | +| Anonymous credential reset initiation | 5 requests/10 minutes/IP | +| Anonymous credential reset completion | 10 requests/10 minutes/IP | +| Self identifier verification | 10 requests/10 minutes/IP | + +These numeric defaults take precedence over the Sensitive group's numeric settings. + +For example, increasing `Sensitive.PermitLimit` does not automatically increase the anonymous reset initiation limit. + +Use an explicit endpoint override to change these limits. + +## Per-endpoint configuration + +Individual endpoint rules can be configured using `RateLimiting.Endpoints`. + +For example, limit session validation to two requests per minute: + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() + { + PermitLimit = 2, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +The third request within the applicable sliding window receives HTTP 429 when the budget is exhausted. + +This example is useful for testing client behavior, but two validations per minute may be too restrictive for a real application with automatic session validation. + +Endpoint overrides are complete replacement rules, not partial changes merged with group numeric settings. + +### Policy precedence + +UltimateAuth resolves endpoint policies in the following order: + +1. Native application endpoint/group rate limiting metadata, including native disable metadata +2. Explicit `Endpoints[id]` replacement +3. Shared Login rule for credential-verification operations +4. Special registration, reset, and verification defaults +5. Endpoint group template + +An explicit endpoint override can replace a built-in rule, select an application-owned named policy, or disable the built-in policy for that endpoint. + +All UltimateAuth endpoint mappings have explicit classifications. Endpoint registration fails when a newly mapped endpoint is missing its required classification. + +## Disabling rate limiting + +To disable UltimateAuth's built-in endpoint policies globally: + +```csharp +options.RateLimiting.Enabled = false; +``` + +To disable an individual built-in rule: + +```csharp +options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() +{ + Enabled = false +}; +``` + +A disabled UltimateAuth rule does not add native `DisableRateLimiting` metadata. + +Application-owned policies and global limiters may still apply. + +To prevent the integrated helper from installing the native middleware: + +```csharp +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); +``` + +These controls have different responsibilities: + +- `RateLimiting.Enabled` controls UltimateAuth's built-in policies. +- Individual rule `Enabled` settings control specific built-in rules. +- `useRateLimiting` controls whether the integrated helper installs the middleware. + +## Using application-owned policies + +Applications can use their own native ASP.NET Core policies instead of an UltimateAuth built-in rule. + +```csharp +builder.Services.AddRateLimiter(options => +{ + options.AddPolicy("my-auth-policy"); +}); + +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.UserCreateAnonymous] = new() + { + PolicyName = "my-auth-policy" + }; +}); +``` + +Named policies must be registered with the native rate limiting options used by the middleware. + +UltimateAuth's built-in policies are inline native policies and do not reserve application policy names. + +Endpoint policies do not automatically compose. Native ASP.NET Core endpoint/group precedence applies. + +Native `DisableRateLimiting` metadata also follows ASP.NET Core semantics and can bypass an application global limiter. + +### Rejection callbacks + +Built-in UltimateAuth inline policies produce their own generic HTTP 429 response. + +They do not invoke the application's middleware-level `OnRejected` callback. + +Application-owned named policies can use native rejection behavior. When a named policy has no policy-level rejection callback, the effective native middleware `OnRejected` callback applies. + +If an application needs complete ownership of rejection responses for a particular endpoint, use an application-owned policy. + +UltimateAuth does not replace application global limiters or application rejection settings. + +## HTTP 429 responses + +When a built-in UltimateAuth policy rejects a request, the server returns: + +- HTTP status `429 Too Many Requests` +- Problem Details content +- Stable detail code `rate_limited` +- `Cache-Control: no-store` +- `Retry-After` when the native limiter supplies retry guidance + +A representative response is: + +```json +{ + "title": "Too Many Requests", + "status": 429, + "detail": "rate_limited" +} +``` + +The exact Problem Details payload may include additional fields. + +`Retry-After` is optional. Applications must not assume that every HTTP 429 response contains it. + +Rejected requests do not execute their endpoint handlers. Consequently, a request rejected at this layer does not consume a reset or PKCE proof, rotate refresh credentials, clear logout cookies, or increment login failure counts through the rejected handler. + +Authentication and other middleware executing before the rate limiter may still perform work. + +### User-facing messages + +`rate_limited` is a stable machine-readable error code, not a localized user-facing message. + +Applications can map it to a message such as: + +> Too many requests. Please wait a moment before trying again. + +If a reliable retry delay is available, the application may display it. + +Automatic background operations should generally handle throttling silently rather than repeatedly notifying the user. + +## Client HTTP 429 handling + +UltimateAuth clients recognize HTTP 429 as a temporary request throttle. + +A 429 response does not establish that: + +- The user's credentials are invalid +- The session has been revoked +- A token has expired +- The user is unauthorized +- Reauthentication is required + +**A throttled request must not, by itself, clear a valid authenticated session.** + +UltimateAuth uses its existing client result and error contracts rather than introducing a separate rate limiting exception hierarchy. + +### Client result contracts + +The following contracts carry throttling information where applicable: + +- `UAuthTransportException` +- `TryLoginResult` +- `TryPkceLoginResult` +- `RefreshResult` +- `UAuthResult` +- `UAuthProblem` + +The stable error code is `rate_limited`. + +Depending on the contract, it is exposed through `Code`, `ErrorCode`, or `Problem.Detail`. + +HTTP status remains 429. + +`UAuthResult.Http.IsTooManyRequests` distinguishes throttling from unauthorized and forbidden results. + +`RetryAfter` provides an optional `TimeSpan` where supported. + +Transport DTOs preserve the raw `Retry-After` header across the JavaScript bridge. + +The existing `IUAuthTryResult` interface provides default metadata properties so existing implementations are not required to add new members. + +The public `IFlowClient` method signatures remain unchanged. + +## Client behavior by operation + +| Operation | HTTP 429 behavior | +|---|---| +| Validation | Throws a transport exception with status 429 and throttling metadata | +| Blazor authentication state | Preserves the current identity, claims, and last successful validation time | +| Refresh | Returns an unsuccessful result with status 429 and `Outcome = None` | +| Automatic refresh | Defers subsequent attempts without triggering reauthentication | +| Login preview | Returns a distinguishable throttled result rather than invalid credentials | +| PKCE preview | Returns a distinguishable throttled result | +| Try-and-commit | Does not submit the final commit after a throttled preview | +| Logout | Does not report successful logout or navigate on a confirmed 429 | +| Authorization | Returns a throttled failure, not a Forbidden result | +| Profile, user, credential, and recovery operations | Return unsuccessful results without publishing success events | +| Resource requests | Preserve HTTP 429 information without changing authentication state | + +### Validation + +`ValidateAsync` throws `UAuthTransportException` for HTTP 429 before attempting to deserialize a successful authentication result. + +Blazor's `UAuthStateManager.EnsureAsync` recognizes this condition and preserves the current authentication snapshot. + +It does not: + +- Clear the current identity +- Remove claims +- Treat the user as anonymous +- Mark the session freshly validated +- Update the last successful validation timestamp + +Subsequent validation attempts, including forced validation calls, are deferred during the cooldown. + +A genuine invalid, expired, or revoked validation result continues to follow the normal state-clearing behavior. + +### Refresh + +A throttled refresh returns an unsuccessful `RefreshResult` with: + +```text +StatusCode: 429 +ErrorCode: rate_limited +Outcome: None +``` + +Misleading authentication-outcome headers are ignored on a throttled response. + +Repeated refresh calls during the cooldown return the throttled result without issuing another HTTP request. + +Automatic refresh scheduling respects both the configured refresh interval and the throttling deadline. + +HTTP 429 does not cause reauthentication events, logout, or automatic navigation. + +A genuine refresh HTTP 401 can still produce `ReauthRequired`. + +### Login and PKCE + +Login and PKCE preview operations expose throttling distinctly from invalid credentials. + +When a TryAndCommit preview is throttled, the client does not submit the final commit form. + +However, final browser-navigation form submissions retain their existing navigation semantics. + +For `LoginAsync`, DirectCommit, `CompletePkceLoginAsync`, and the final TryAndCommit submission, the browser owns the resulting HTTP response. + +If that final POST receives HTTP 429, the browser may display the server response page. + +The JavaScript client cannot reliably return the final navigated response's `Retry-After` metadata through the existing navigation contract. + +Preview success or form submission completion must not be interpreted as confirmation that a session was issued. + +### Logout + +Logout uses an observed form POST so the client can distinguish a confirmed successful response from HTTP 429. + +On HTTP 429: + +- No successful logout event is published. +- No success navigation occurs. +- The client does not clear authentication state solely because of throttling. + +On confirmed success, the existing logout event can update client authentication state. + +Logout navigation is different from login navigation: the observed request can follow a successful server redirect and then navigate without replaying the logout POST. + +Cross-origin logout still requires compatible browser credentials, CORS configuration, and redirect behavior. + +A network or redirect failure may leave the server-side logout outcome uncertain. The SDK reports an error rather than claiming success or automatically replaying the operation. + +Applications can explicitly revalidate when connectivity permits. + +### Custom client transports + +`IUAuthRequestClient` supports an observed form request overload: + +```csharp +SendFormAsync(endpoint, form, navigateOnSuccess, ct) +``` + +The overload has a default implementation that delegates to the original form request method. + +Existing custom transport implementations therefore retain their original interface requirements. + +However, the default implementation does not provide navigation behavior automatically. + +Custom adapters that support logout navigation should implement the observed overload and preserve HTTP status and `Retry-After` metadata. + +## Retry-After and cooldown behavior + +UltimateAuth clients support standard `Retry-After` values expressed as: + +- Delta seconds +- HTTP-date values + +Parsing uses invariant culture and UTC. + +Missing, malformed, or inaccessible values remain null in application results. Past dates produce a zero retry delay. + +For internal maintenance scheduling, the SDK uses: + +- A conservative 30-second fallback when retry guidance is unavailable +- A one-second minimum to avoid immediate retry loops +- Bounded timer intervals when waiting for long delays + +Long retry guidance is honored by the current implementation. + +The SDK does not automatically retry arbitrary application operations. + +In particular, it does not automatically replay login, logout, recovery, credential mutations, or administrative changes after throttling. + +For cross-origin browser clients, the server must expose `Retry-After` through CORS if JavaScript is expected to read that header. + +When the header is not accessible, the client follows its missing-header behavior. + +## Authentication state safety + +Rate limiting is deliberately separated from authentication validity. + +A throttled request does not: + +- Extend token expiration +- Renew credentials +- Update successful validation timestamps +- Create a trusted identity +- Revoke an existing session +- Clear browser storage automatically +- Publish successful mutation events + +`UAuthStateManager` remains responsible for authentication state transitions. + +`SessionCoordinator` remains responsible for automatic refresh scheduling and genuine reauthentication handling. + +`UAuthAuthenticationStateProvider` projects the retained state to Blazor. + +Explicit local actions such as `Clear` and `OnLogoutAsync`, and authoritative revocation or mutation events, retain their existing behavior. + +## Partitioning and trust boundaries + +UltimateAuth chooses rate limiting partitions using trusted server-side information. + +Credential entry, anonymous registration and recovery, identifier availability, PKCE, Hub entry, and self-proof verification use the connection IP even when a cookie is present. + +Other operations use validated UltimateAuth tenant and actor information when available, otherwise the connection IP. + +The following do not independently establish a trusted authenticated partition: + +- Mere presence of a session cookie +- Usernames or target user identifiers +- Unvalidated tokens +- Device identifiers +- Client profile hints +- Raw tenant selectors +- Principals from unrelated authentication authorities + +Authenticated actor partitions include validated tenant identity. + +Anonymous IP budgets span tenants, preventing a client from multiplying its quota simply by rotating tenant selectors. + +IPv4-mapped IPv6 addresses are normalized to IPv4. + +Requests without an available connection address share an unknown-address partition. + +UltimateAuth does not trust arbitrary forwarding headers. + +Configure trusted proxies, forwarded headers, and ingress restrictions at the application or infrastructure level. + +Applications using another identity authority should configure an appropriate native policy rather than assuming UltimateAuth can validate that authority's principal. + +## Persistence and distributed deployment + +Built-in counters are maintained in application memory. + +They are not stored in UltimateAuth's persistence providers, EF Core, or a database. + +This means: + +- Counters reset when the application process restarts. +- Each application instance maintains its own counters. +- Browser refreshes do not reset server counters. +- Sliding windows replenish capacity over time. +- Multiple application instances do not automatically share a global request budget. + +For distributed deployments requiring fleet-wide limits, use an ingress gateway, WAF, or custom native policy backed by shared infrastructure. + +Built-in native partitions have idle cleanup, but do not provide a hard partition-cardinality bound. + +Large-scale IP rotation, IPv6 address churn, NAT sharing, and botnet traffic require additional operational consideration. + +Rate limiting does not replace account lockout, proof-attempt limits, anti-enumeration responses, or other authentication security controls. + +Direct service calls and Blazor Server circuit operations are outside HTTP endpoint throttling unless the application protects them separately. + +## Testing rate limiting + +A simple development test is to temporarily limit validation to two requests per minute: + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() + { + PermitLimit = 2, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +After starting the application and authenticating, send repeated requests to: + +```http +POST /auth/validate +``` + +The expected server behavior is: + +| Request | Expected result | +|---|---| +| First permitted request | Normal validation | +| Second permitted request | Normal validation | +| Request exceeding the budget | HTTP 429 | +| Request after capacity replenishes | Normal validation | + +Actual results depend on the partition's existing traffic and sliding-window state. + +When testing through an UltimateAuth client, the client may defer additional validation requests after receiving HTTP 429. This is expected and should be tested separately from server enforcement. + +Also verify that: + +- Authentication state remains intact after throttling. +- Other independently limited operations continue to work. +- Automatic refresh does not enter an immediate retry loop. +- Login preview does not confuse throttling with invalid credentials. +- Logout does not report success after a confirmed 429. +- No successful mutation event is published for a rejected operation. + +Remove temporary low-limit overrides before deploying to production. + +### Client IP Address & Reverse Proxies + +UltimateAuth uses ASP.NET Core's resolved client IP address for IP-based rate limiting. It does not directly trust forwarded IP headers. + +When deploying behind Caddy, Nginx, Cloudflare, or another reverse proxy, ensure that ASP.NET Core's Forwarded Headers Middleware is correctly configured with trusted proxies or networks. + +Without proper configuration, multiple users may be identified by the same proxy IP address and share rate limits. + +See [Client IP Address & Reverse Proxy Setup](/docs/getting-started/real-world-setup#10-client-ip-address--reverse-proxy) for configuration examples. + +## Related documentation + +- [Server Options](/docs/configuration/server-options) +- [Client Options](/docs/configuration/client-options) +- [Authentication Model](/docs/concepts/authentication-model) + +Rate limiting is one layer of UltimateAuth's security model. It should be combined with appropriate credential policies, session validation, trusted proxy configuration, and deployment-level protection. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj index 69a55d41..a4e8ea48 100644 --- 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 @@ -11,10 +11,10 @@ - + - - + + 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 index 6301b33e..8b993cce 100644 --- 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 @@ -25,6 +25,10 @@
Docs Samples + + Identity Lab + Donate
@@ -68,6 +72,7 @@ Samples + Identity Lab Donate @@ -79,9 +84,7 @@ - + @@ -89,17 +92,14 @@ - - UltimateAuth - - - Open-source unified Auth for modern .NET. - + UltimateAuth + Open-source unified Auth for modern .NET. Docs Samples + Identity Lab GitHub Discord @@ -110,7 +110,7 @@ - Β© @DateTime.Now.Year CodeBeam. + Β© @DateTime.Now.Year CodeBeam. @@ -124,7 +124,7 @@ -
+
An unhandled error has occurred. 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 index e00fb76c..65b41518 100644 --- 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 @@ -90,6 +90,148 @@ +
+ + + +
+ + +
+ +
+
+ +
+ +
+ +
+ + LIVE INTERACTIVE SANDBOX +
+ + + Meet Identity Lab. + Authentication, experienced. + + + + Go beyond documentation and code samples. + Explore UltimateAuth inside a real, + interactive Blazor application. + + + + Create your own sandbox, manage identities, + inspect sessions, experiment with credentials + and explore authorization β€” all powered by + UltimateAuth. + + +
+ + + + +
+ +
+ Identity Management +
+
+ Explore users, profiles and identifiers. +
+
+
+ + +
+ +
+ Session Lifecycle +
+
+ Discover device-aware sessions and + session lineage. +
+
+
+ + +
+ +
+ Credentials +
+
+ Experience credential management + and account security. +
+
+
+ + +
+ +
+ Authorization +
+
+ Explore roles, permissions and + policy-driven access. +
+
+
+ +
+ +
+ + + Launch Identity Lab + + + + Explore Documentation + + +
+ +
+ + + Identity Lab is a demonstration environment. + Use test data only. Sandbox data may be reset. + +
+ +
+
+ +
+
+
diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor.css new file mode 100644 index 00000000..d556ad95 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor.css @@ -0,0 +1,221 @@ +.ua-lab-section { + position: relative; + padding: 96px 0; +} + +.ua-lab-showcase { + position: relative; + overflow: hidden; + isolation: isolate; + border-radius: 28px; + border: 1px solid rgba(130, 155, 255, 0.22); + background: radial-gradient( ellipse at 80% 0%, rgba(88, 91, 240, 0.22), transparent 55% ), linear-gradient( 145deg, #10182d 0%, #0b1120 55%, #111a30 100% ); + color: #f5f7ff; + box-shadow: 0 28px 90px rgba(5, 12, 30, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.ua-lab-content { + position: relative; + z-index: 1; + padding: clamp(32px, 6vw, 80px); +} + +.ua-lab-ambient { + position: absolute; + z-index: 0; + pointer-events: none; + border-radius: 50%; + filter: blur(70px); +} + +.ua-lab-ambient-one { + width: 360px; + height: 360px; + top: -180px; + right: -100px; + background: rgba(110, 97, 255, 0.18); +} + +.ua-lab-ambient-two { + width: 280px; + height: 280px; + bottom: -160px; + left: -90px; + background: rgba(42, 170, 220, 0.12); +} + +.ua-lab-heading { + max-width: 820px; +} + +.ua-lab-eyebrow { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border: 1px solid rgba(155, 174, 255, 0.25); + border-radius: 100px; + background: rgba(255, 255, 255, 0.045); + color: #aebfff; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.13em; + margin-bottom: 28px; +} + +.ua-lab-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #57e4ae; + box-shadow: 0 0 12px rgba(87, 228, 174, 0.6); +} + +.ua-lab-title { + font-weight: 800 !important; + letter-spacing: -0.045em !important; + line-height: 1.12 !important; + color: #fff !important; + margin-bottom: 28px !important; + overflow-wrap: break-word; + min-width: 0; +} + + .ua-lab-title span { + display: block; + background: linear-gradient( 90deg, #b5c3ff, #8ee5ff, #c5b6ff ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + } + +.ua-lab-description { + max-width: 690px; + color: #b6c2d9 !important; + line-height: 1.85 !important; + margin-bottom: 14px !important; +} + +.ua-lab-features { + margin-top: 44px; +} + +.ua-lab-feature { + height: 100%; + min-height: 185px; + padding: 24px 20px; + border-radius: 16px; + border: 1px solid rgba(175, 190, 255, 0.14); + background: rgba(255, 255, 255, 0.035); + transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; +} + + .ua-lab-feature:hover { + transform: translateY(-3px); + border-color: rgba(155, 180, 255, 0.38); + background: rgba(255, 255, 255, 0.065); + } + + .ua-lab-feature .mud-icon-root { + color: #a9baff; + margin-bottom: 18px; + } + +.ua-lab-feature-title { + font-size: 0.95rem; + font-weight: 700; + color: #f7f9ff; + margin-bottom: 10px; +} + +.ua-lab-feature-text { + color: #aab8d1; + font-size: 0.85rem; + line-height: 1.65; +} + +.ua-lab-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-top: 44px; +} + +.ua-lab-primary-button { + border-radius: 12px !important; + padding: 12px 26px !important; + font-weight: 700 !important; + box-shadow: 0 8px 28px rgba(100, 110, 255, 0.2); +} + +.ua-lab-secondary-button { + color: #dbe4ff !important; +} + +.ua-lab-footnote { + display: flex; + align-items: flex-start; + gap: 10px; + margin-top: 34px; + color: #91a0bd; + font-size: 0.78rem; + line-height: 1.6; +} + + .ua-lab-footnote .mud-icon-root { + flex-shrink: 0; + font-size: 17px; + } + +@media (max-width: 600px) { + .ua-lab-section { + padding: 52px 0; + } + + .ua-lab-showcase { + border-radius: 20px; + } + + .ua-lab-title { + font-size: clamp(1.75rem, 7vw, 2.5rem) !important; + line-height: 1.18 !important; + letter-spacing: -0.035em !important; + } + + .ua-lab-title span { + font-size: 0.9em; + overflow-wrap: anywhere; + } + + .ua-lab-features { + margin-top: 24px; + } + + .ua-lab-feature { + min-height: auto; + } + + .ua-lab-actions { + align-items: stretch; + flex-direction: column; + } + + .ua-lab-actions .mud-button-root { + width: 100%; + } + + .ua-lab-content { + padding: 32px 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + .ua-lab-feature { + transition: none; + } + + .ua-lab-feature:hover { + transform: none; + } +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Samples.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Samples.razor index dee6fad4..668e5ba0 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Samples.razor +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Samples.razor @@ -5,7 +5,7 @@ Samples Explore working examples and see UltimateAuth in action. - Identity Lab (hosted live demo) is coming soon. + Identity Lab (live sandbox) is also available. @@ -94,7 +94,7 @@ Follow the project combinations below to run them locally. - + Blazor Server @@ -108,7 +108,7 @@ - + Blazor Standalone WASM @@ -127,7 +127,7 @@ - + Blazor Interactive WASM diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json index 2dad947d..3559b544 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json @@ -1,7 +1,7 @@ { "Slug": "auth-flows/logout-flow", "Title": "Logout", - "Html": "\n\u003Cp\u003EAuthentication does not end when a client deletes a cookie or forgets a token. In UltimateAuth, ending access is a \u003Cstrong\u003Eserver-side lifecycle decision\u003C/strong\u003E: the system determines which authentication state must stop being usable, at what scope, and whether the associated device relationship should survive a future login.\u003C/p\u003E\n\u003Cp\u003EThat distinction is central to UltimateAuth\u0027s design. \u003Cstrong\u003ELogout ends access; revoke permanently invalidates the selected trust boundary.\u003C/strong\u003E Both can prevent a session from being used, but they express different intentions and have different consequences for the session hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-is-more-than-clearing-credentials\u0022\u003ELogout is more than clearing credentials\u003C/h2\u003E\n\u003Cp\u003EA traditional application may implement logout by removing a browser cookie or deleting a locally stored token. That is useful client cleanup, but by itself it does not necessarily invalidate a credential already issued by the server. A copied token could remain usable until its expiration if the server has no corresponding invalidation mechanism.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead models authentication around a hierarchy:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n\u2514\u2500\u2500 Root Global authentication lineage\n \u251C\u2500\u2500 Chain Device-associated authentication lineage\n \u2502 \u251C\u2500\u2500 Session Individual authentication instance\n \u2502 \u2514\u2500\u2500 Session Another instance in the same chain\n \u2514\u2500\u2500 Chain Another device\n \u2514\u2500\u2500 Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis structure gives termination a meaningful scope. An application can end access on the current device, sign out other devices, sign out all devices, or explicitly revoke a session, chain, or root. The correct choice depends on the intent: ordinary sign-out, account security, device compromise, or global trust reset.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EMental model:\u003C/strong\u003E Logout is a controlled exit from authentication. Revoke is a permanent invalidation of the selected authentication state. A later login may establish new state, but it does not undo a revocation.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-revoke\u0022\u003ELogout versus revoke\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-end-access-without-necessarily-destroying-the-device-lineage\u0022\u003ELogout: end access without necessarily destroying the device lineage\u003C/h3\u003E\n\u003Cp\u003EAn ordinary logout is designed for situations such as a user selecting \u003Cstrong\u003ESign out\u003C/strong\u003E in an application. It ends the relevant authenticated access while allowing the device-associated chain to remain available for a subsequent login, subject to its state and lifetime rules.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBefore logout\nRoot (active)\n\u2514\u2500\u2500 Chain (active)\n \u2514\u2500\u2500 Session (authenticated)\n\nAfter logout\nRoot (active)\n\u2514\u2500\u2500 Chain (retained)\n \u2514\u2500\u2500 Session (no longer usable)\n\nAfter a later successful login\nRoot (active)\n\u2514\u2500\u2500 Chain (reusable if still eligible)\n \u2514\u2500\u2500 New Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERetaining a chain does not mean retaining an authenticated session.\u003C/strong\u003E The user must authenticate again. Reuse is conditional: an expired, revoked, or otherwise ineligible chain must not be treated as active merely because it still exists in storage.\u003C/p\u003E\n\u003Cp\u003EA subtle but important implementation detail: the current HTTP logout flow resolves the authenticated session and invokes the flow service, whose logout command identifies and logs out the \u003Cstrong\u003Eassociated chain\u003C/strong\u003E. Consequently, the default logout endpoint should not be described as a guarantee that \u003Cem\u003Eonly one session record\u003C/em\u003E changes. Its practical boundary is the current device chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-permanently-invalidate-the-selected-trust-boundary\u0022\u003ERevoke: permanently invalidate the selected trust boundary\u003C/h3\u003E\n\u003Cp\u003ERevocation is a stronger security operation. A revoked session, chain, or root is not restored by a subsequent login, refresh, or cookie update.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EChain (active)\n\u2514\u2500\u2500 Session (active)\n\nRevoke chain\n\nChain (revoked \u2014 terminal state)\n\u2514\u2500\u2500 Sessions (invalidated)\n\nFuture authentication \u2192 new eligible chain, not restoration of the old one\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERevocation is irreversible for the revoked entity.\u003C/strong\u003E This does not mean the user is permanently locked out of the application. A legitimate user may authenticate again and establish a new session lineage if account policy allows it. What cannot happen is bringing the \u003Cem\u003Erevoked\u003C/em\u003E session or chain back to life.\u003C/p\u003E\n\u003Cp\u003EThis distinction is useful in security-sensitive scenarios: a lost device, suspected credential theft, administrator intervention, or a decision to invalidate all existing authentication relationships.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-termination-scope\u0022\u003EChoosing the termination scope\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EIntended scope\u003C/th\u003E\n\u003Cth\u003EWhat it means\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout current device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session\u0027s chain\u003C/td\u003E\n\u003Ctd\u003EEnds authenticated access for the current device while preserving the possibility of later eligible chain reuse.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout a device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected chain\u003C/td\u003E\n\u003Ctd\u003EEnds access on the chosen device; can be initiated by the user or an authorized administrator.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout other devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOther device sessions\u003C/td\u003E\n\u003Ctd\u003EEnds access elsewhere while preserving the specified current device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout all devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAll user sessions\u003C/td\u003E\n\u003Ctd\u003ESigns the user out across devices without treating this as a root revocation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke session\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne session\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates the selected session.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke chain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne device lineage\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates that chain and its sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke other/all chains\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMultiple device lineages\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates selected device relationships.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke root\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EGlobal authentication lineage\u003C/td\u003E\n\u003Ctd\u003EInvalidates the root and cascades to its chains and sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are not interchangeable aliases. In particular, \u003Cstrong\u003Elogout all devices\u003C/strong\u003E and \u003Cstrong\u003Erevoke root\u003C/strong\u003E both disrupt broad access, but they do not express the same lifecycle intent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-session-level-revoke-is-separate\u0022\u003EWhy session-level revoke is separate\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes a session revocation operation distinct from the ordinary logout endpoint. A single session can be revoked without necessarily revoking its parent chain. This is useful when an application needs to invalidate a particular authentication instance rather than the entire device relationship.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-root-revoke-is-exceptional\u0022\u003EWhy root revoke is exceptional\u003C/h3\u003E\n\u003Cp\u003EThe root represents the user\u0027s active authentication lineage, not the user\u0027s existence in the user directory. Root revocation cascades through the associated authentication hierarchy. A later permitted login may establish a \u003Cstrong\u003Enew\u003C/strong\u003E active root; it does not reverse the old root\u0027s revocation.\u003C/p\u003E\n\u003Cp\u003ERoot revocation should therefore be reserved for an intentional global trust reset rather than ordinary sign-out.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-the-current-logout-flow-executes\u0022\u003EHow the current logout flow executes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-flow-context\u0022\u003E1. Resolve the flow context\u003C/h3\u003E\n\u003Cp\u003EThe HTTP endpoint obtains the current \u003Ccode\u003EAuthFlowContext\u003C/code\u003E. This carries the relevant authentication state and response configuration, including the resolved session when one is available.\u003C/p\u003E\n\u003Cp\u003EThe ordinary logout endpoint does not accept an arbitrary session ID from the browser and blindly invalidate it. It uses the session already resolved for the current flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022request-server-side-termination\u0022\u003E2. Request server-side termination\u003C/h3\u003E\n\u003Cp\u003EWhen a \u003Ccode\u003ESessionSecurityContext\u003C/code\u003E is present, the endpoint constructs a \u003Ccode\u003ELogoutRequest\u003C/code\u003E using that session ID and calls the flow service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutRequest\n{\n SessionId = session.SessionId\n};\n\nawait _client.Flows.LogoutAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe logout command resolves the session\u0027s chain and invokes the session issuer\u0027s \u003Ccode\u003ELogoutChainAsync\u003C/code\u003E operation. The session-store implementation performs the chain logout transition.\u003C/p\u003E\n\u003Cp\u003EThis is the authoritative part of logout. Removing a client cookie alone is not a substitute for this state transition.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-cookies-configured-for-this-flow\u0022\u003E3. Clear cookies configured for this flow\u003C/h3\u003E\n\u003Cp\u003EAfter the server-side operation, the HTTP handler attempts to delete credentials whose \u003Cstrong\u003Eeffective delivery mode is Cookie\u003C/strong\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession ID cookie\u003C/li\u003E\n\u003Cli\u003ERefresh token cookie\u003C/li\u003E\n\u003Cli\u003EAccess token cookie\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe handler uses the configured cookie names through \u003Ccode\u003EIUAuthCookieManager\u003C/code\u003E. It does not assume that all three credentials are cookies, and it does not attempt to delete header-delivered credentials from a remote client\u0027s storage.\u003C/p\u003E\n\u003Cp\u003EWhen the flow has no resolved session, the endpoint can still perform cookie cleanup and return a successful logout response. This makes sign-out useful even when a browser holds stale credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-response-and-optional-redirect\u0022\u003E4. Resolve the response and optional redirect\u003C/h3\u003E\n\u003Cp\u003EOnce termination and cookie cleanup have been handled, UltimateAuth evaluates the logout response configuration. The result is either a redirect to the configured destination or a normal HTTP success response.\u003C/p\u003E\n\u003Cp\u003EWith redirect disabled, the ordinary endpoint returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;isSuccess\u0026quot;: true\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-redirect-matters\u0022\u003EWhy redirect matters\u003C/h2\u003E\n\u003Cp\u003EA redirect is \u003Cstrong\u003Enot required to invalidate authentication state\u003C/strong\u003E. It is a navigation concern that becomes important in browser-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EConsider a server-rendered application with a dedicated sign-out page. Once the session has ended and cookies are cleared, the browser should often return to a public page, a login screen, or the page originally requested by the user. Redirecting completes that user journey without requiring the application to parse a logout response and navigate manually.\u003C/p\u003E\n\u003Cp\u003ERedirects can also help when the authentication endpoint and the client application are on different origins or when a centralized authentication service serves multiple clients. The server can perform the security operation and then send the browser back to the appropriate application destination.\u003C/p\u003E\n\u003Cp\u003EIn contrast, a mobile client or API consumer commonly prefers an ordinary HTTP result and controls its own navigation. \u003Cstrong\u003ERedirect is a response behavior, not a security primitive.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-resolution-in-ultimateauth\u0022\u003ERedirect resolution in UltimateAuth\u003C/h3\u003E\n\u003Cp\u003EThe response resolver selects the effective redirect settings for the logout flow. The redirect resolver then:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EChecks whether redirect is enabled.\u003C/li\u003E\n\u003Cli\u003EOn success, considers an allowed return URL override when configured.\u003C/li\u003E\n\u003Cli\u003EOtherwise uses the configured success path, resolved against the client base address.\u003C/li\u003E\n\u003Cli\u003EIf no valid destination is configured, leaves redirect disabled and allows a normal success response.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor absolute return URL overrides, the resolver restricts schemes to HTTP or HTTPS and validates the origin against configured allowed client origins when that allowlist is populated. Relative paths are resolved against the selected client base address.\u003C/p\u003E\n\u003Cp\u003EThis is why a return URL should be treated as \u003Cstrong\u003Evalidated navigation input\u003C/strong\u003E, never as an unrestricted arbitrary destination. Applications should configure allowed origins carefully, especially in multi-client or hosted scenarios.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Do not use a redirect as proof that logout succeeded, or assume that clearing browser cookies invalidates every credential issued to every device. The server-side termination scope determines what access has actually ended.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-aware-logout\u0022\u003EDevice-aware logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats the device-associated chain as a first-class authentication boundary. That allows applications to build features such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of this device\u003C/strong\u003E \u2014 end the current device\u0027s authenticated access.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of another device\u003C/strong\u003E \u2014 target a specific chain after the appropriate access checks.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of other devices\u003C/strong\u003E \u2014 preserve the current device while ending other sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out everywhere\u003C/strong\u003E \u2014 end all device sessions for the user.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke a lost device\u003C/strong\u003E \u2014 permanently invalidate the compromised chain instead of merely signing it out.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese operations are exposed through distinct application-service methods, rather than overloading a single logout call with ambiguous behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-actions\u0022\u003ESelf-service and administrative actions\u003C/h3\u003E\n\u003Cp\u003EDevice and all-device HTTP endpoints use access contexts carrying explicit action identifiers. They are executed through the access orchestrator, which enforces the applicable authorization decision before the requested state mutation.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003ELogoutDeviceSelfAsync\u003C/code\u003E targets the authenticated user\u0027s device, whereas \u003Ccode\u003ELogoutDeviceAdminAsync\u003C/code\u003E accepts a target user and executes under an administrative action context. Both ultimately call the device logout application service; authorization and target ownership are not left to the client.\u003C/p\u003E\n\u003Cp\u003EThe default current-device logout endpoint follows its own flow-based path. Do not assume every termination operation shares the same exact handler or authorization pipeline.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-properties-and-boundaries\u0022\u003ESecurity properties and boundaries\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-invalidation-comes-first\u0022\u003EServer-side invalidation comes first\u003C/h3\u003E\n\u003Cp\u003EThe server must stop recognizing the affected authentication state. Browser cleanup improves user experience and reduces accidental credential reuse, but it is not the source of truth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoked-state-is-terminal\u0022\u003ERevoked state is terminal\u003C/h3\u003E\n\u003Cp\u003ERevocation cannot be undone by setting \u003Ccode\u003EIsRevoked\u003C/code\u003E back to false, presenting an old refresh token, or logging in again with the same device. A new authentication path must establish new eligible state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022scope-must-be-explicit\u0022\u003EScope must be explicit\u003C/h3\u003E\n\u003Cp\u003EA session revoke should not be casually described as a chain revoke. A logout-all operation should not be described as a root revoke. These distinctions determine whether existing device lineages are retained, ended, or permanently invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-and-ownership-matter\u0022\u003EAuthorization and ownership matter\u003C/h3\u003E\n\u003Cp\u003ESelf-service operations must stay within the authenticated user\u0027s permitted scope. Administrative operations must be evaluated under their corresponding action and target context. A client-provided chain identifier is not sufficient authorization to terminate someone else\u0027s session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-delivery-affects-cleanup-not-the-termination-model\u0022\u003EToken delivery affects cleanup, not the termination model\u003C/h3\u003E\n\u003Cp\u003EWhether a client receives credentials through cookies or headers, the server-side hierarchy still governs session-backed authentication. Clients using non-cookie delivery must also clear their own local credentials after successful termination.\u003C/p\u003E\n\u003Cp\u003EThe precise behavior of already-issued, independently validated tokens depends on the active authentication mode and token validation strategy. Avoid assuming every token format provides instantaneous server-side revocation without checking its validation model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022practical-examples\u0022\u003EPractical examples\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-signs-out-at-the-end-of-the-day\u0022\u003EA user signs out at the end of the day\u003C/h3\u003E\n\u003Cp\u003EUse the ordinary logout flow. The current device\u0027s authenticated access ends, configured cookies are cleared, and the browser may be redirected to a public page. A later successful login can reuse the retained device chain if it remains eligible.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-loses-a-phone\u0022\u003EA user loses a phone\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Echain revoke\u003C/strong\u003E, not merely a soft device logout, when the goal is to permanently invalidate that device\u0027s existing trust lineage. The user may later authenticate on a replacement device, but the revoked chain stays revoked.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-wants-to-keep-this-device-but-sign-out-elsewhere\u0022\u003EA user wants to keep this device but sign out elsewhere\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Elogout other devices\u003C/strong\u003E. The current device\u0027s chain is excluded while other device sessions are terminated. This is different from permanently revoking every other chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022an-account-is-believed-to-be-compromised\u0022\u003EAn account is believed to be compromised\u003C/h3\u003E\n\u003Cp\u003EDepending on the security policy, revoke all chains or the root. This is a stronger response than ordinary logout because it deliberately invalidates existing authentication lineages. Credential rotation and additional account recovery steps may also be required; revocation alone does not prove that compromised credentials have been changed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-misconceptions\u0022\u003ECommon misconceptions\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout just deletes the cookie.\u201D\u003C/strong\u003E No. The ordinary endpoint performs server-side termination when a session is resolved, then clears applicable cookies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout and revoke are the same operation.\u201D\u003C/strong\u003E No. Both can stop access, but revocation is permanent for the selected entity; soft logout can retain eligible device lineage.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout always means exactly one session.\u201D\u003C/strong\u003E Not in the current default HTTP flow. The resolved session identifies a chain, and the logout command operates on that chain.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRevocation permanently bans the user.\u201D\u003C/strong\u003E No. It permanently invalidates existing authentication state at the selected scope. A permitted future login may create new state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRedirect makes logout secure.\u201D\u003C/strong\u003E No. Redirect controls where the browser goes after logout; the session-store transition is what ends server-recognized access.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout all devices is the same as revoke root.\u201D\u003C/strong\u003E No. They are different lifecycle operations with different implications for the authentication hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogout is a lifecycle operation\u003C/strong\u003E, not merely client-side credential deletion.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke is irreversible\u003C/strong\u003E for the revoked session, chain, or root; a future login never restores that entity.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERoot \u2192 Chain \u2192 Session\u003C/strong\u003E makes termination scope explicit and device-aware.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECurrent-device logout operates through the associated chain\u003C/strong\u003E in the current server implementation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOther-device and all-device logout\u003C/strong\u003E are separate from hard chain/root revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERedirect is optional navigation behavior\u003C/strong\u003E, especially useful for browser-based flows; it does not perform revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer-side state is authoritative\u003C/strong\u003E for session-backed access; client credential cleanup remains important.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003E\u27A1\uFE0F Next step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Cstrong\u003ESession Lifecycle\u003C/strong\u003E to understand how roots, chains, and sessions are created, rotated, expired, logged out, and revoked over time.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EAuthentication does not end when a client deletes a cookie or forgets a token. In UltimateAuth, ending access is a \u003Cstrong\u003Eserver-side lifecycle decision\u003C/strong\u003E: the system determines which authentication state must stop being usable, at what scope, and whether the associated device relationship should survive a future login.\u003C/p\u003E\n\u003Cp\u003EThat distinction is central to UltimateAuth\u0027s design. \u003Cstrong\u003ELogout ends access; revoke permanently invalidates the selected trust boundary.\u003C/strong\u003E Both can prevent a session from being used, but they express different intentions and have different consequences for the session hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-is-more-than-clearing-credentials\u0022\u003ELogout is more than clearing credentials\u003C/h2\u003E\n\u003Cp\u003EA traditional application may implement logout by removing a browser cookie or deleting a locally stored token. That is useful client cleanup, but by itself it does not necessarily invalidate a credential already issued by the server. A copied token could remain usable until its expiration if the server has no corresponding invalidation mechanism.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead models authentication around a hierarchy:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n\u2514\u2500\u2500 Root Global authentication lineage\n \u251C\u2500\u2500 Chain Device-associated authentication lineage\n \u2502 \u251C\u2500\u2500 Session Individual authentication instance\n \u2502 \u2514\u2500\u2500 Session Another instance in the same chain\n \u2514\u2500\u2500 Chain Another device\n \u2514\u2500\u2500 Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis structure gives termination a meaningful scope. An application can end access on the current device, sign out other devices, sign out all devices, or explicitly revoke a session, chain, or root. The correct choice depends on the intent: ordinary sign-out, account security, device compromise, or global trust reset.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EMental model:\u003C/strong\u003E Logout is a controlled exit from authentication. Revoke is a permanent invalidation of the selected authentication state. A later login may establish new state, but it does not undo a revocation.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-revoke\u0022\u003ELogout versus revoke\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-end-access-without-necessarily-destroying-the-device-lineage\u0022\u003ELogout: end access without necessarily destroying the device lineage\u003C/h3\u003E\n\u003Cp\u003EAn ordinary logout is designed for situations such as a user selecting \u003Cstrong\u003ESign out\u003C/strong\u003E in an application. It ends the relevant authenticated access while allowing the device-associated chain to remain available for a subsequent login, subject to its state and lifetime rules.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBefore logout\nRoot (active)\n\u2514\u2500\u2500 Chain (active)\n \u2514\u2500\u2500 Session (authenticated)\n\nAfter logout\nRoot (active)\n\u2514\u2500\u2500 Chain (retained)\n \u2514\u2500\u2500 Session (no longer usable)\n\nAfter a later successful login\nRoot (active)\n\u2514\u2500\u2500 Chain (reusable if still eligible)\n \u2514\u2500\u2500 New Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERetaining a chain does not mean retaining an authenticated session.\u003C/strong\u003E The user must authenticate again. Reuse is conditional: an expired, revoked, or otherwise ineligible chain must not be treated as active merely because it still exists in storage.\u003C/p\u003E\n\u003Cp\u003EA subtle but important implementation detail: the current HTTP logout flow resolves the authenticated session and invokes the flow service, whose logout command identifies and logs out the \u003Cstrong\u003Eassociated chain\u003C/strong\u003E. Consequently, the default logout endpoint should not be described as a guarantee that \u003Cem\u003Eonly one session record\u003C/em\u003E changes. Its practical boundary is the current device chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-permanently-invalidate-the-selected-trust-boundary\u0022\u003ERevoke: permanently invalidate the selected trust boundary\u003C/h3\u003E\n\u003Cp\u003ERevocation is a stronger security operation. A revoked session, chain, or root is not restored by a subsequent login, refresh, or cookie update.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EChain (active)\n\u2514\u2500\u2500 Session (active)\n\nRevoke chain\n\nChain (revoked \u2014 terminal state)\n\u2514\u2500\u2500 Sessions (invalidated)\n\nFuture authentication \u2192 new eligible chain, not restoration of the old one\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERevocation is irreversible for the revoked entity.\u003C/strong\u003E This does not mean the user is permanently locked out of the application. A legitimate user may authenticate again and establish a new session lineage if account policy allows it. What cannot happen is bringing the \u003Cem\u003Erevoked\u003C/em\u003E session or chain back to life.\u003C/p\u003E\n\u003Cp\u003EThis distinction is useful in security-sensitive scenarios: a lost device, suspected credential theft, administrator intervention, or a decision to invalidate all existing authentication relationships.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-termination-scope\u0022\u003EChoosing the termination scope\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EIntended scope\u003C/th\u003E\n\u003Cth\u003EWhat it means\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout current device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session\u0027s chain\u003C/td\u003E\n\u003Ctd\u003EEnds authenticated access for the current device while preserving the possibility of later eligible chain reuse.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout a device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected chain\u003C/td\u003E\n\u003Ctd\u003EEnds access on the chosen device; can be initiated by the user or an authorized administrator.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout other devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOther device sessions\u003C/td\u003E\n\u003Ctd\u003EEnds access elsewhere while preserving the specified current device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout all devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAll user sessions\u003C/td\u003E\n\u003Ctd\u003ESigns the user out across devices without treating this as a root revocation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke session\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne session\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates the selected session.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke chain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne device lineage\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates that chain and its sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke other/all chains\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMultiple device lineages\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates selected device relationships.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke root\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EGlobal authentication lineage\u003C/td\u003E\n\u003Ctd\u003EInvalidates the root and cascades to its chains and sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are not interchangeable aliases. In particular, \u003Cstrong\u003Elogout all devices\u003C/strong\u003E and \u003Cstrong\u003Erevoke root\u003C/strong\u003E both disrupt broad access, but they do not express the same lifecycle intent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-session-level-revoke-is-separate\u0022\u003EWhy session-level revoke is separate\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes a session revocation operation distinct from the ordinary logout endpoint. A single session can be revoked without necessarily revoking its parent chain. This is useful when an application needs to invalidate a particular authentication instance rather than the entire device relationship.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-root-revoke-is-exceptional\u0022\u003EWhy root revoke is exceptional\u003C/h3\u003E\n\u003Cp\u003EThe root represents the user\u0027s active authentication lineage, not the user\u0027s existence in the user directory. Root revocation cascades through the associated authentication hierarchy. A later permitted login may establish a \u003Cstrong\u003Enew\u003C/strong\u003E active root; it does not reverse the old root\u0027s revocation.\u003C/p\u003E\n\u003Cp\u003ERoot revocation should therefore be reserved for an intentional global trust reset rather than ordinary sign-out.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-the-current-logout-flow-executes\u0022\u003EHow the current logout flow executes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-flow-context\u0022\u003E1. Resolve the flow context\u003C/h3\u003E\n\u003Cp\u003EThe HTTP endpoint obtains the current \u003Ccode\u003EAuthFlowContext\u003C/code\u003E. This carries the relevant authentication state and response configuration, including the resolved session when one is available.\u003C/p\u003E\n\u003Cp\u003EThe ordinary logout endpoint does not accept an arbitrary session ID from the browser and blindly invalidate it. It uses the session already resolved for the current flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022request-server-side-termination\u0022\u003E2. Request server-side termination\u003C/h3\u003E\n\u003Cp\u003EWhen a \u003Ccode\u003ESessionSecurityContext\u003C/code\u003E is present, the endpoint constructs a \u003Ccode\u003ELogoutRequest\u003C/code\u003E using that session ID and calls the flow service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutRequest\n{\n SessionId = session.SessionId\n};\n\nawait _client.Flows.LogoutAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe logout command resolves the session\u0027s chain and invokes the session issuer\u0027s \u003Ccode\u003ELogoutChainAsync\u003C/code\u003E operation. The session-store implementation performs the chain logout transition.\u003C/p\u003E\n\u003Cp\u003EThis is the authoritative part of logout. Removing a client cookie alone is not a substitute for this state transition.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-cookies-configured-for-this-flow\u0022\u003E3. Clear cookies configured for this flow\u003C/h3\u003E\n\u003Cp\u003EAfter the server-side operation, the HTTP handler attempts to delete credentials whose \u003Cstrong\u003Eeffective delivery mode is Cookie\u003C/strong\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession ID cookie\u003C/li\u003E\n\u003Cli\u003ERefresh token cookie\u003C/li\u003E\n\u003Cli\u003EAccess token cookie\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe handler uses the configured cookie names through \u003Ccode\u003EIUAuthCookieManager\u003C/code\u003E. It does not assume that all three credentials are cookies, and it does not attempt to delete header-delivered credentials from a remote client\u0027s storage.\u003C/p\u003E\n\u003Cp\u003EWhen the flow has no resolved session, the endpoint can still perform cookie cleanup and return a successful logout response. This makes sign-out useful even when a browser holds stale credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-response-and-optional-redirect\u0022\u003E4. Resolve the response and optional redirect\u003C/h3\u003E\n\u003Cp\u003EOnce termination and cookie cleanup have been handled, UltimateAuth evaluates the logout response configuration. The result is either a redirect to the configured destination or a normal HTTP success response.\u003C/p\u003E\n\u003Cp\u003EWith redirect disabled, the ordinary endpoint returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;isSuccess\u0026quot;: true\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-controlled-navigation\u0022\u003EClient-controlled navigation\u003C/h3\u003E\n\u003Cp\u003EThe UltimateAuth Client API allows applications to control navigation after a successful logout through the optional \u003Ccode\u003Enavigate\u003C/code\u003E parameter.\u003C/p\u003E\n\u003Cp\u003EBy default, logout follows the server-configured redirect behavior:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETo complete logout without automatic navigation, set \u003Ccode\u003Enavigate\u003C/code\u003E to \u003Ccode\u003Efalse\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync(navigate: false);\n\n// Perform application-specific cleanup after logout.\nawait YourApplicationCleanupAsync();\n\nNavigation.NavigateTo(\u0026quot;/goodbye\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003Enavigate\u003C/code\u003E is \u003Ccode\u003Efalse\u003C/code\u003E, UltimateAuth still performs the server-side logout, clears applicable authentication cookies, and publishes the logout event so the client authentication state can be cleared. Only automatic navigation is suppressed.\u003C/p\u003E\n\u003Cp\u003EThis is useful when an application needs to clear its own state, perform additional cleanup, display a confirmation, or choose a destination after logout.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E The \u003Ccode\u003Enavigate\u003C/code\u003E parameter controls client-side navigation, not session termination or server-side redirect configuration. If logout fails, including with HTTP 429, the operation does not publish a successful logout event. Applications should perform post-logout actions only after \u003Ccode\u003ELogoutAsync\u003C/code\u003E completes successfully.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-redirect-matters\u0022\u003EWhy redirect matters\u003C/h2\u003E\n\u003Cp\u003EA redirect is \u003Cstrong\u003Enot required to invalidate authentication state\u003C/strong\u003E. It is a navigation concern that becomes important in browser-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EConsider a server-rendered application with a dedicated sign-out page. Once the session has ended and cookies are cleared, the browser should often return to a public page, a login screen, or the page originally requested by the user. Redirecting completes that user journey without requiring the application to parse a logout response and navigate manually.\u003C/p\u003E\n\u003Cp\u003ERedirects can also help when the authentication endpoint and the client application are on different origins or when a centralized authentication service serves multiple clients. The server can perform the security operation and then send the browser back to the appropriate application destination.\u003C/p\u003E\n\u003Cp\u003EIn contrast, a mobile client or API consumer commonly prefers an ordinary HTTP result and controls its own navigation. \u003Cstrong\u003ERedirect is a response behavior, not a security primitive.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-resolution-in-ultimateauth\u0022\u003ERedirect resolution in UltimateAuth\u003C/h3\u003E\n\u003Cp\u003EThe response resolver selects the effective redirect settings for the logout flow. The redirect resolver then:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EChecks whether redirect is enabled.\u003C/li\u003E\n\u003Cli\u003EOn success, considers an allowed return URL override when configured.\u003C/li\u003E\n\u003Cli\u003EOtherwise uses the configured success path, resolved against the client base address.\u003C/li\u003E\n\u003Cli\u003EIf no valid destination is configured, leaves redirect disabled and allows a normal success response.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor absolute return URL overrides, the resolver restricts schemes to HTTP or HTTPS and validates the origin against configured allowed client origins when that allowlist is populated. Relative paths are resolved against the selected client base address.\u003C/p\u003E\n\u003Cp\u003EThis is why a return URL should be treated as \u003Cstrong\u003Evalidated navigation input\u003C/strong\u003E, never as an unrestricted arbitrary destination. Applications should configure allowed origins carefully, especially in multi-client or hosted scenarios.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Do not use a redirect as proof that logout succeeded, or assume that clearing browser cookies invalidates every credential issued to every device. The server-side termination scope determines what access has actually ended.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-aware-logout\u0022\u003EDevice-aware logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats the device-associated chain as a first-class authentication boundary. That allows applications to build features such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of this device\u003C/strong\u003E \u2014 end the current device\u0027s authenticated access.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of another device\u003C/strong\u003E \u2014 target a specific chain after the appropriate access checks.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of other devices\u003C/strong\u003E \u2014 preserve the current device while ending other sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out everywhere\u003C/strong\u003E \u2014 end all device sessions for the user.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke a lost device\u003C/strong\u003E \u2014 permanently invalidate the compromised chain instead of merely signing it out.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese operations are exposed through distinct application-service methods, rather than overloading a single logout call with ambiguous behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-actions\u0022\u003ESelf-service and administrative actions\u003C/h3\u003E\n\u003Cp\u003EDevice and all-device HTTP endpoints use access contexts carrying explicit action identifiers. They are executed through the access orchestrator, which enforces the applicable authorization decision before the requested state mutation.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003ELogoutDeviceSelfAsync\u003C/code\u003E targets the authenticated user\u0027s device, whereas \u003Ccode\u003ELogoutDeviceAdminAsync\u003C/code\u003E accepts a target user and executes under an administrative action context. Both ultimately call the device logout application service; authorization and target ownership are not left to the client.\u003C/p\u003E\n\u003Cp\u003EThe default current-device logout endpoint follows its own flow-based path. Do not assume every termination operation shares the same exact handler or authorization pipeline.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-properties-and-boundaries\u0022\u003ESecurity properties and boundaries\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-invalidation-comes-first\u0022\u003EServer-side invalidation comes first\u003C/h3\u003E\n\u003Cp\u003EThe server must stop recognizing the affected authentication state. Browser cleanup improves user experience and reduces accidental credential reuse, but it is not the source of truth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoked-state-is-terminal\u0022\u003ERevoked state is terminal\u003C/h3\u003E\n\u003Cp\u003ERevocation cannot be undone by setting \u003Ccode\u003EIsRevoked\u003C/code\u003E back to false, presenting an old refresh token, or logging in again with the same device. A new authentication path must establish new eligible state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022scope-must-be-explicit\u0022\u003EScope must be explicit\u003C/h3\u003E\n\u003Cp\u003EA session revoke should not be casually described as a chain revoke. A logout-all operation should not be described as a root revoke. These distinctions determine whether existing device lineages are retained, ended, or permanently invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-and-ownership-matter\u0022\u003EAuthorization and ownership matter\u003C/h3\u003E\n\u003Cp\u003ESelf-service operations must stay within the authenticated user\u0027s permitted scope. Administrative operations must be evaluated under their corresponding action and target context. A client-provided chain identifier is not sufficient authorization to terminate someone else\u0027s session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-delivery-affects-cleanup-not-the-termination-model\u0022\u003EToken delivery affects cleanup, not the termination model\u003C/h3\u003E\n\u003Cp\u003EWhether a client receives credentials through cookies or headers, the server-side hierarchy still governs session-backed authentication. Clients using non-cookie delivery must also clear their own local credentials after successful termination.\u003C/p\u003E\n\u003Cp\u003EThe precise behavior of already-issued, independently validated tokens depends on the active authentication mode and token validation strategy. Avoid assuming every token format provides instantaneous server-side revocation without checking its validation model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022practical-examples\u0022\u003EPractical examples\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-signs-out-at-the-end-of-the-day\u0022\u003EA user signs out at the end of the day\u003C/h3\u003E\n\u003Cp\u003EUse the ordinary logout flow. The current device\u0027s authenticated access ends, configured cookies are cleared, and the browser may be redirected to a public page. A later successful login can reuse the retained device chain if it remains eligible.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-loses-a-phone\u0022\u003EA user loses a phone\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Echain revoke\u003C/strong\u003E, not merely a soft device logout, when the goal is to permanently invalidate that device\u0027s existing trust lineage. The user may later authenticate on a replacement device, but the revoked chain stays revoked.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-wants-to-keep-this-device-but-sign-out-elsewhere\u0022\u003EA user wants to keep this device but sign out elsewhere\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Elogout other devices\u003C/strong\u003E. The current device\u0027s chain is excluded while other device sessions are terminated. This is different from permanently revoking every other chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022an-account-is-believed-to-be-compromised\u0022\u003EAn account is believed to be compromised\u003C/h3\u003E\n\u003Cp\u003EDepending on the security policy, revoke all chains or the root. This is a stronger response than ordinary logout because it deliberately invalidates existing authentication lineages. Credential rotation and additional account recovery steps may also be required; revocation alone does not prove that compromised credentials have been changed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-misconceptions\u0022\u003ECommon misconceptions\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout just deletes the cookie.\u201D\u003C/strong\u003E No. The ordinary endpoint performs server-side termination when a session is resolved, then clears applicable cookies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout and revoke are the same operation.\u201D\u003C/strong\u003E No. Both can stop access, but revocation is permanent for the selected entity; soft logout can retain eligible device lineage.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout always means exactly one session.\u201D\u003C/strong\u003E Not in the current default HTTP flow. The resolved session identifies a chain, and the logout command operates on that chain.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRevocation permanently bans the user.\u201D\u003C/strong\u003E No. It permanently invalidates existing authentication state at the selected scope. A permitted future login may create new state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRedirect makes logout secure.\u201D\u003C/strong\u003E No. Redirect controls where the browser goes after logout; the session-store transition is what ends server-recognized access.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout all devices is the same as revoke root.\u201D\u003C/strong\u003E No. They are different lifecycle operations with different implications for the authentication hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogout is a lifecycle operation\u003C/strong\u003E, not merely client-side credential deletion.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke is irreversible\u003C/strong\u003E for the revoked session, chain, or root; a future login never restores that entity.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERoot \u2192 Chain \u2192 Session\u003C/strong\u003E makes termination scope explicit and device-aware.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECurrent-device logout operates through the associated chain\u003C/strong\u003E in the current server implementation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOther-device and all-device logout\u003C/strong\u003E are separate from hard chain/root revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERedirect is optional navigation behavior\u003C/strong\u003E, especially useful for browser-based flows; it does not perform revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer-side state is authoritative\u003C/strong\u003E for session-backed access; client credential cleanup remains important.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003E\u27A1\uFE0F Next step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Cstrong\u003ESession Lifecycle\u003C/strong\u003E to understand how roots, chains, and sessions are created, rotated, expired, logged out, and revoked over time.\u003C/p\u003E\n", "Headings": [ { "Id": "logout-is-more-than-clearing-credentials", @@ -63,6 +63,11 @@ "Text": "4. Resolve the response and optional redirect", "Level": 1 }, + { + "Id": "client-controlled-navigation", + "Text": "Client-controlled navigation", + "Level": 1 + }, { "Id": "why-redirect-matters", "Text": "Why redirect matters", @@ -154,6 +159,6 @@ "Level": 0 } ], - "SourceHash": "16E601FE603117928E01E64029AC1BDF0F5AD93F8108808F07817D0A2EB608CE", + "SourceHash": "D47451D3FA67648678F48B2ECC2626D88F9E49C7B36DFAA9922FB7AE6372CE33", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/overview.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/overview.json index 4ec11523..3865758a 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/overview.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/overview.json @@ -1,7 +1,7 @@ { "Slug": "auth-flows/overview", "Title": "Auth-Flows Overview", - "Html": "\n\u003Cp\u003EAuthentication is often described as a collection of endpoints: one accepts a password, another renews a token, and another clears a cookie. Those endpoints are useful interfaces, but they are not the security model.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth treats authentication as a set of controlled flows operating within an explicitly resolved security context.\u003C/strong\u003E A flow represents an intention\u2014establish access, continue it, or terminate it\u2014and the server determines which rules, state, and delivery behavior apply before executing that intention.\u003C/p\u003E\n\u003Cp\u003EThis distinction matters because the same request can have different legitimate outcomes depending on the tenant, authentication mode, client profile, device, current session, and server policy. A flow should not have to guess those values independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-flow-is-more-than-an-endpoint\u0022\u003EA flow is more than an endpoint\u003C/h2\u003E\n\u003Cp\u003EAn endpoint receives an HTTP request. A flow describes what the request is attempting to do and provides a consistent context for the operation.\u003C/p\u003E\n\u003Cp\u003EIn UltimateAuth, the HTTP boundary, context resolution, domain orchestration, authorization decisions, persistence, and response delivery have separate responsibilities. This makes authentication behavior easier to reason about without forcing every flow to use exactly the same internal implementation.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003ECore principle:\u003C/strong\u003E Resolve the security context first. Make decisions against authoritative state. Commit only the changes permitted by the flow. Then deliver the outcome in the form appropriate for the client.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-request-to-result-pipeline\u0022\u003EThe request-to-result pipeline\u003C/h2\u003E\n\u003Cp\u003EThe following sequence describes the common \u003Cstrong\u003EHTTP auth-flow boundary\u003C/strong\u003E. Individual flows have additional steps, and their internal order may differ.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EIncoming HTTP request\n |\n v\nUltimateAuth Middleware Pipeline\n |\n \u002B-- TenantMiddleware\n | - Resolve tenant context\n | - Store tenant context in HttpContext\n |\n \u002B-- SessionResolutionMiddleware\n | - Resolve session identifier\n | - Build SessionContext (or anonymous context)\n |\n \u002B-- UserMiddleware\n | - Resolve user context through IUserAccessor\n |\n |-- ASP.NET Core Authentication and Authorization Middleware (Optional)\n |\n v\nRequest Context Prepared\n |\n v\nEndpoint metadata (AuthFlowType)\n |\n v\nAuthFlowEndpointFilter\n |\n v\nAuthFlow.BeginAsync(...)\n |\n v\nAuthFlowContextFactory\n - Read tenant, session, and user contexts\n - Resolve client profile and effective server options\n - Determine effective authentication mode\n - Resolve primary token kind and response delivery policy\n - Resolve device context and return URL\n - Validate existing session where applicable\n - Reject invalid or unresolved tenant\n |\n v\nAuthFlowContext (request-scoped)\n |\n v\nEndpoint handler and flow-specific validation\n |\n v\nFlow service / orchestrator\n |\n v\nSecurity and authorization decisions\n |\n v\nPermitted state transitions and optional token operations\n |\n v\nResponse delivery, optional redirect, and flow events\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a \u003Cstrong\u003Econceptual pipeline\u003C/strong\u003E, not a promise that every flow creates a session, issues a token, or dispatches the same events. Nor is there one universal authority method that all flows invoke identically.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-request-identifies-the-flow\u0022\u003E1. A request identifies the flow\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth endpoints can carry \u003Ccode\u003EAuthFlowMetadata\u003C/code\u003E describing the \u003Ccode\u003EAuthFlowType\u003C/code\u003E. The \u003Ccode\u003EAuthFlowEndpointFilter\u003C/code\u003E reads that metadata and invokes \u003Ccode\u003EIAuthFlow.BeginAsync(...)\u003C/code\u003E before the endpoint handler executes.\u003C/p\u003E\n\u003Cp\u003EThis is an important boundary: the endpoint does not need to assemble its own authentication environment from scattered headers, cookies, and settings.\u003C/p\u003E\n\u003Cp\u003EThe filter initializes the flow context; it does \u003Cstrong\u003Enot\u003C/strong\u003E, by itself, validate a password, authorize every operation, or mutate the session store. Those responsibilities belong to the appropriate downstream components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-server-constructs-authflowcontext\u0022\u003E2. The server constructs \u003Ccode\u003EAuthFlowContext\u003C/code\u003E\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContextFactory\u003C/code\u003E resolves the inputs that govern how the operation must behave. The resulting context includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EContext value\u003C/th\u003E\n\u003Cth\u003EWhy it matters\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the intended authentication operation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEstablishes the tenant boundary for state and decisions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribes the kind of client interacting with the server.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEffectiveMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDetermines the authentication model actually in force for this request.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESupplies the effective server policy.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the primary credential/token kind for the effective mode.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries the resolved device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries validated session security context when available.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAuthenticated\u003C/code\u003E / \u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERepresent the resolved authentication identity for this request.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribes effective credential delivery and redirect behavior.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrlInfo\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries parsed navigation intent for applicable browser flows.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe factory rejects unresolved or invalid tenant context and disallowed authentication modes. When a session context is present, it invokes session validation. For session-authoritative modes such as \u003Ccode\u003EPureOpaque\u003C/code\u003E and \u003Ccode\u003EHybrid\u003C/code\u003E, that validation also affects the resolved authentication state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe context is resolved, not blindly trusted.\u003C/strong\u003E Request-provided information may influence resolution, but the effective mode, policy, identity, and session validity are determined by server-side components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-context-is-made-available-to-the-handler\u0022\u003E3. The context is made available to the handler\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlow.BeginAsync\u003C/code\u003E stores the created context through the request-scoped \u003Ccode\u003EAuthFlowContextAccessor\u003C/code\u003E. The endpoint handler can then access the same resolved context rather than reconstructing it.\u003C/p\u003E\n\u003Cp\u003EThis creates a shared vocabulary across the HTTP boundary: the handler and downstream services work with the same tenant, mode, device, and response policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022flow-specific-validation-and-orchestration-begin\u0022\u003E4. Flow-specific validation and orchestration begin\u003C/h3\u003E\n\u003Cp\u003EOnce the context exists, the endpoint handler reads and validates the operation-specific request. For example, login needs an identifier and secret; logout needs an applicable session or target scope; refresh needs valid renewal material.\u003C/p\u003E\n\u003Cp\u003EThe handler delegates the actual operation to the relevant flow service or orchestrator. Those components coordinate credential checks, session operations, token operations, and state transitions as appropriate.\u003C/p\u003E\n\u003Cp\u003EThere is no benefit in pretending these operations are identical. \u003Cstrong\u003ETheir shared architecture is the context and security boundary; their individual state transitions remain purpose-specific.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022decisions-are-evaluated-against-authoritative-state\u0022\u003E5. Decisions are evaluated against authoritative state\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth distinguishes between what a client \u003Cem\u003Epresents\u003C/em\u003E and what the system \u003Cem\u003Eknows\u003C/em\u003E.\u003C/p\u003E\n\u003Cp\u003EA cookie, bearer token, device identifier, or user-supplied value is evidence or input\u2014not automatically the source of truth. The relevant authoritative data comes from server-managed user, credential, authentication-security, and session state, subject to the active authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor session-backed flows, the hierarchy is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n \u2514\u2500\u2500 Root\n \u2514\u2500\u2500 Chain (device-associated lifecycle)\n \u2514\u2500\u2500 Session (authentication instance)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe root, chain, and session provide distinct scopes for continuity, validation, and invalidation. Tokens and cookies can represent access to that state, but should not be confused with the underlying lifecycle.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EMode distinction:\u003C/strong\u003E Do not assume that every mode has identical persistence or session-issuance behavior. In particular, \u003Ccode\u003EPureJwt\u003C/code\u003E is not a session-issuance mode in the current session issuer. The effective mode determines which state and proof are authoritative for a given operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022only-permitted-transitions-are-committed\u0022\u003E6. Only permitted transitions are committed\u003C/h3\u003E\n\u003Cp\u003EA successful authentication operation may create a session, rotate or touch existing state, revoke access, or leave persistent state unchanged. The actual effect depends on the flow and mode.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin verifies credentials and, in session-issuing modes, establishes a session before optionally issuing tokens.\u003C/li\u003E\n\u003Cli\u003ERefresh evaluates existing renewal authority and applies its configured continuation behavior; it is not simply a request to mint another token.\u003C/li\u003E\n\u003Cli\u003ELogout ends access at the relevant scope, while revoke represents irreversible invalidation of the revoked object or hierarchy scope.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EWhere a session command passes through \u003Ccode\u003EUAuthSessionOrchestrator\u003C/code\u003E, \u003Ccode\u003EIAuthAuthority\u003C/code\u003E makes an allow, deny, or challenge decision before the session issuer executes that command. Other operations use their own relevant decision and access orchestration paths.\u003C/p\u003E\n\u003Cp\u003EThis separation helps prevent HTTP transport details from becoming security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-outcome-is-delivered-according-to-policy\u0022\u003E7. The outcome is delivered according to policy\u003C/h3\u003E\n\u003Cp\u003EA successful domain operation and the HTTP response carrying its outcome are different concerns.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthResponseResolver\u003C/code\u003E derives the effective response configuration from the authentication mode, flow type, client profile, and server options. It binds applicable cookie settings and validates the delivery configuration before the endpoint performs the operation.\u003C/p\u003E\n\u003Cp\u003EThis means a flow can use the same underlying authentication semantics while producing a client-appropriate HTTP outcome\u2014for example, a response with managed cookies or a browser redirect when configured.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation note:\u003C/strong\u003E \u003Ccode\u003ETokenResponseMode.Body\u003C/code\u003E credential delivery is explicitly unsupported by the response resolver. Do not assume that access or refresh credentials are returned in a JSON response body.\u003C/p\u003E\n\u003Cp\u003EA redirect is a navigation decision, \u003Cstrong\u003Enot\u003C/strong\u003E an authentication or revocation mechanism. It can return a browser to the application after login or logout; the underlying security operation must stand independently of whether the browser follows the redirect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-authflowcontext-matters\u0022\u003EWhy \u003Ccode\u003EAuthFlowContext\u003C/code\u003E matters\u003C/h2\u003E\n\u003Cp\u003EWithout a shared flow context, individual endpoints tend to make their own assumptions: which tenant is active, whether a cookie represents an authenticated session, which token type is expected, whether a client should receive cookies, and where to navigate afterward.\u003C/p\u003E\n\u003Cp\u003EThose assumptions drift as applications add multiple clients, devices, tenants, or authentication modes.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead resolves these questions at the flow boundary. That provides three practical benefits:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EConsistency.\u003C/strong\u003E The same effective policy is available throughout the operation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESeparation of concerns.\u003C/strong\u003E Endpoint handlers handle HTTP; orchestrators coordinate security-relevant work; response components handle delivery.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EExtensibility.\u003C/strong\u003E New client profiles and flow behaviors can integrate with established boundaries instead of duplicating authentication plumbing.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EA context is not an authorization bypass. Possessing an \u003Ccode\u003EAuthFlowContext\u003C/code\u003E does not imply that the requested operation has been approved.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022source-of-truth-proof-versus-state\u0022\u003ESource of truth: proof versus state\u003C/h2\u003E\n\u003Cp\u003EA useful mental model is to distinguish \u003Cstrong\u003Eproof\u003C/strong\u003E, \u003Cstrong\u003Epolicy\u003C/strong\u003E, and \u003Cstrong\u003Estate\u003C/strong\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EExamples\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProof\u003C/td\u003E\n\u003Ctd\u003EPassword, session identifier, access token, refresh token\u003C/td\u003E\n\u003Ctd\u003EPresents evidence for an operation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPolicy\u003C/td\u003E\n\u003Ctd\u003EEffective mode, server options, authority decisions\u003C/td\u003E\n\u003Ctd\u003EDetermines what is permitted.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EState\u003C/td\u003E\n\u003Ctd\u003EUser eligibility, credential records, security state, session root/chain/session\u003C/td\u003E\n\u003Ctd\u003EDetermines the authoritative lifecycle and validity where applicable.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDelivery\u003C/td\u003E\n\u003Ctd\u003ECookies, supported response modes, redirect\u003C/td\u003E\n\u003Ctd\u003ECommunicates the result to the client.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA valid-looking token is not, by itself, permission to ignore revocation, tenant boundaries, session state, or mode-specific validation. Conversely, not every authentication mode is built around a persisted session hierarchy. The authoritative checks must match the configured model.\u003C/p\u003E\n\u003Cp\u003EThis distinction is central to UltimateAuth\u0027s vision: \u003Cstrong\u003Eauthentication is not the possession of a string; it is the outcome of a controlled security process.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022built-in-flows-at-a-glance\u0022\u003EBuilt-in flows at a glance\u003C/h2\u003E\n\u003Cp\u003EThe following pages explain each operation in detail. This overview focuses on their roles within the shared model.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EFlow\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003Cth\u003EPractical outcome\u003C/th\u003E\n\u003Cth\u003EGuide\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogin\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EEstablish authenticated access from acceptable credentials.\u003C/td\u003E\n\u003Ctd\u003ECreates a valid authentication session in session-issuing modes; may issue tokens according to policy.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./login-flow.md\u0022\u003ELogin Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERefresh\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContinue an existing authentication relationship using renewal authority.\u003C/td\u003E\n\u003Ctd\u003EMaintains or renews access according to mode, validation, and configured refresh behavior.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./refresh-flow.md\u0022\u003ERefresh Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd access at the applicable session or device scope.\u003C/td\u003E\n\u003Ctd\u003ETerminates authentication access and handles configured client cleanup/navigation.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./logout-flow.md\u0022\u003ELogout Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese flows are related but not interchangeable. Login establishes access; refresh continues it; logout terminates it. Revocation operations provide stronger invalidation controls for the relevant security scope.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022concepts-shared-by-the-flows\u0022\u003EConcepts shared by the flows\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EWhat it contributes\u003C/th\u003E\n\u003Cth\u003EGuide\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession lifecycle\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERoot, chain, and session relationships; creation, continuity, expiry, and invalidation.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./session-lifecycle.md\u0022\u003ESession Lifecycle\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EToken behavior\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh credentials, validation, renewal, and mode-specific behavior.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./token-behavior.md\u0022\u003EToken Behavior\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EDevice management\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated chains, multi-device access, and targeted termination.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022./device-management.md\u0022\u003EDevice Management\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-this-design-enables-in-real-applications\u0022\u003EWhat this design enables in real applications\u003C/h2\u003E\n\u003Cp\u003EConsider an application with a browser front end, a mobile client, and an administrative interface. They may share the same user identity system, but they do not necessarily have the same credential delivery needs or session behavior.\u003C/p\u003E\n\u003Cp\u003EA flow-driven architecture lets the server establish the applicable context first and then run the requested operation under that context. Developers can reason about \u003Cstrong\u003Ewhat authentication action is taking place\u003C/strong\u003E separately from \u003Cstrong\u003Ehow the resulting credentials or navigation are delivered\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also makes security-sensitive behavior easier to locate: tenant and mode resolution occur at the boundary, domain validation happens in the flow, session operations use dedicated infrastructure, and response policy is resolved independently.\u003C/p\u003E\n\u003Cp\u003EThis is the larger goal of UltimateAuth\u0027s unified model\u2014not to make every authentication operation identical, but to make their differences explicit, controlled, and composable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EAn auth flow is a controlled operation\u003C/strong\u003E, not merely an HTTP endpoint.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EAuthFlowEndpointFilter\u003C/code\u003E initializes the request context\u003C/strong\u003E for endpoints carrying flow metadata.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E resolves tenant, mode, client profile, device, identity/session information, and response policy\u003C/strong\u003E before the handler executes.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EProof is not the source of truth.\u003C/strong\u003E Validation depends on authoritative state and the effective authentication mode.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EAuthority and orchestration are distinct responsibilities.\u003C/strong\u003E The exact checks and state transitions vary by flow.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECredential delivery and redirects are outcomes\u003C/strong\u003E, not substitutes for security decisions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogin, refresh, and logout share a model without sharing identical execution paths.\u003C/strong\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003ENext step\u003C/h2\u003E\n\u003Cp\u003EStart with \u003Ca href=\u0022/docs/auth-flows/login\u0022\u003ELogin Flow\u003C/a\u003E to see how UltimateAuth turns validated credentials into controlled authenticated access.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EAuthentication is often described as a collection of endpoints: one accepts a password, another renews a token, and another clears a cookie. Those endpoints are useful interfaces, but they are not the security model.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth treats authentication as a set of controlled flows operating within an explicitly resolved security context.\u003C/strong\u003E A flow represents an intention\u2014establish access, continue it, or terminate it\u2014and the server determines which rules, state, and delivery behavior apply before executing that intention.\u003C/p\u003E\n\u003Cp\u003EThis distinction matters because the same request can have different legitimate outcomes depending on the tenant, authentication mode, client profile, device, current session, and server policy. A flow should not have to guess those values independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-flow-is-more-than-an-endpoint\u0022\u003EA flow is more than an endpoint\u003C/h2\u003E\n\u003Cp\u003EAn endpoint receives an HTTP request. A flow describes what the request is attempting to do and provides a consistent context for the operation.\u003C/p\u003E\n\u003Cp\u003EIn UltimateAuth, the HTTP boundary, context resolution, domain orchestration, authorization decisions, persistence, and response delivery have separate responsibilities. This makes authentication behavior easier to reason about without forcing every flow to use exactly the same internal implementation.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003ECore principle:\u003C/strong\u003E Resolve the security context first. Make decisions against authoritative state. Commit only the changes permitted by the flow. Then deliver the outcome in the form appropriate for the client.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-request-to-result-pipeline\u0022\u003EThe request-to-result pipeline\u003C/h2\u003E\n\u003Cp\u003EThe following sequence describes the common \u003Cstrong\u003EHTTP auth-flow boundary\u003C/strong\u003E. Individual flows have additional steps, and their internal order may differ.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EIncoming HTTP request\n |\n v\nUltimateAuth Middleware Pipeline\n |\n \u002B-- TenantMiddleware\n | - Resolve tenant context\n | - Store tenant context in HttpContext\n |\n \u002B-- SessionResolutionMiddleware\n | - Resolve session identifier\n | - Build SessionContext (or anonymous context)\n |\n \u002B-- UserMiddleware\n | - Resolve user context through IUserAccessor\n |\n |-- ASP.NET Core Authentication and Authorization Middleware (Optional)\n |\n v\nRequest Context Prepared\n |\n v\nEndpoint metadata (AuthFlowType)\n |\n v\nAuthFlowEndpointFilter\n |\n v\nAuthFlow.BeginAsync(...)\n |\n v\nAuthFlowContextFactory\n - Read tenant, session, and user contexts\n - Resolve client profile and effective server options\n - Determine effective authentication mode\n - Resolve primary token kind and response delivery policy\n - Resolve device context and return URL\n - Validate existing session where applicable\n - Reject invalid or unresolved tenant\n |\n v\nAuthFlowContext (request-scoped)\n |\n v\nEndpoint handler and flow-specific validation\n |\n v\nFlow service / orchestrator\n |\n v\nSecurity and authorization decisions\n |\n v\nPermitted state transitions and optional token operations\n |\n v\nResponse delivery, optional redirect, and flow events\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a \u003Cstrong\u003Econceptual pipeline\u003C/strong\u003E, not a promise that every flow creates a session, issues a token, or dispatches the same events. Nor is there one universal authority method that all flows invoke identically.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-request-identifies-the-flow\u0022\u003E1. A request identifies the flow\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth endpoints can carry \u003Ccode\u003EAuthFlowMetadata\u003C/code\u003E describing the \u003Ccode\u003EAuthFlowType\u003C/code\u003E. The \u003Ccode\u003EAuthFlowEndpointFilter\u003C/code\u003E reads that metadata and invokes \u003Ccode\u003EIAuthFlow.BeginAsync(...)\u003C/code\u003E before the endpoint handler executes.\u003C/p\u003E\n\u003Cp\u003EThis is an important boundary: the endpoint does not need to assemble its own authentication environment from scattered headers, cookies, and settings.\u003C/p\u003E\n\u003Cp\u003EThe filter initializes the flow context; it does \u003Cstrong\u003Enot\u003C/strong\u003E, by itself, validate a password, authorize every operation, or mutate the session store. Those responsibilities belong to the appropriate downstream components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-server-constructs-authflowcontext\u0022\u003E2. The server constructs \u003Ccode\u003EAuthFlowContext\u003C/code\u003E\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContextFactory\u003C/code\u003E resolves the inputs that govern how the operation must behave. The resulting context includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EContext value\u003C/th\u003E\n\u003Cth\u003EWhy it matters\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the intended authentication operation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEstablishes the tenant boundary for state and decisions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribes the kind of client interacting with the server.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEffectiveMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDetermines the authentication model actually in force for this request.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESupplies the effective server policy.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the primary credential/token kind for the effective mode.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries the resolved device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries validated session security context when available.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAuthenticated\u003C/code\u003E / \u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERepresent the resolved authentication identity for this request.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribes effective credential delivery and redirect behavior.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrlInfo\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECarries parsed navigation intent for applicable browser flows.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe factory rejects unresolved or invalid tenant context and disallowed authentication modes. When a session context is present, it invokes session validation. For session-authoritative modes such as \u003Ccode\u003EPureOpaque\u003C/code\u003E and \u003Ccode\u003EHybrid\u003C/code\u003E, that validation also affects the resolved authentication state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe context is resolved, not blindly trusted.\u003C/strong\u003E Request-provided information may influence resolution, but the effective mode, policy, identity, and session validity are determined by server-side components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-context-is-made-available-to-the-handler\u0022\u003E3. The context is made available to the handler\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlow.BeginAsync\u003C/code\u003E stores the created context through the request-scoped \u003Ccode\u003EAuthFlowContextAccessor\u003C/code\u003E. The endpoint handler can then access the same resolved context rather than reconstructing it.\u003C/p\u003E\n\u003Cp\u003EThis creates a shared vocabulary across the HTTP boundary: the handler and downstream services work with the same tenant, mode, device, and response policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022flow-specific-validation-and-orchestration-begin\u0022\u003E4. Flow-specific validation and orchestration begin\u003C/h3\u003E\n\u003Cp\u003EOnce the context exists, the endpoint handler reads and validates the operation-specific request. For example, login needs an identifier and secret; logout needs an applicable session or target scope; refresh needs valid renewal material.\u003C/p\u003E\n\u003Cp\u003EThe handler delegates the actual operation to the relevant flow service or orchestrator. Those components coordinate credential checks, session operations, token operations, and state transitions as appropriate.\u003C/p\u003E\n\u003Cp\u003EThere is no benefit in pretending these operations are identical. \u003Cstrong\u003ETheir shared architecture is the context and security boundary; their individual state transitions remain purpose-specific.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022decisions-are-evaluated-against-authoritative-state\u0022\u003E5. Decisions are evaluated against authoritative state\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth distinguishes between what a client \u003Cem\u003Epresents\u003C/em\u003E and what the system \u003Cem\u003Eknows\u003C/em\u003E.\u003C/p\u003E\n\u003Cp\u003EA cookie, bearer token, device identifier, or user-supplied value is evidence or input\u2014not automatically the source of truth. The relevant authoritative data comes from server-managed user, credential, authentication-security, and session state, subject to the active authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor session-backed flows, the hierarchy is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n \u2514\u2500\u2500 Root\n \u2514\u2500\u2500 Chain (device-associated lifecycle)\n \u2514\u2500\u2500 Session (authentication instance)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe root, chain, and session provide distinct scopes for continuity, validation, and invalidation. Tokens and cookies can represent access to that state, but should not be confused with the underlying lifecycle.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EMode distinction:\u003C/strong\u003E Do not assume that every mode has identical persistence or session-issuance behavior. In particular, \u003Ccode\u003EPureJwt\u003C/code\u003E is not a session-issuance mode in the current session issuer. The effective mode determines which state and proof are authoritative for a given operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022only-permitted-transitions-are-committed\u0022\u003E6. Only permitted transitions are committed\u003C/h3\u003E\n\u003Cp\u003EA successful authentication operation may create a session, rotate or touch existing state, revoke access, or leave persistent state unchanged. The actual effect depends on the flow and mode.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin verifies credentials and, in session-issuing modes, establishes a session before optionally issuing tokens.\u003C/li\u003E\n\u003Cli\u003ERefresh evaluates existing renewal authority and applies its configured continuation behavior; it is not simply a request to mint another token.\u003C/li\u003E\n\u003Cli\u003ELogout ends access at the relevant scope, while revoke represents irreversible invalidation of the revoked object or hierarchy scope.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EWhere a session command passes through \u003Ccode\u003EUAuthSessionOrchestrator\u003C/code\u003E, \u003Ccode\u003EIAuthAuthority\u003C/code\u003E makes an allow, deny, or challenge decision before the session issuer executes that command. Other operations use their own relevant decision and access orchestration paths.\u003C/p\u003E\n\u003Cp\u003EThis separation helps prevent HTTP transport details from becoming security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-outcome-is-delivered-according-to-policy\u0022\u003E7. The outcome is delivered according to policy\u003C/h3\u003E\n\u003Cp\u003EA successful domain operation and the HTTP response carrying its outcome are different concerns.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthResponseResolver\u003C/code\u003E derives the effective response configuration from the authentication mode, flow type, client profile, and server options. It binds applicable cookie settings and validates the delivery configuration before the endpoint performs the operation.\u003C/p\u003E\n\u003Cp\u003EThis means a flow can use the same underlying authentication semantics while producing a client-appropriate HTTP outcome\u2014for example, a response with managed cookies or a browser redirect when configured.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation note:\u003C/strong\u003E \u003Ccode\u003ETokenResponseMode.Body\u003C/code\u003E credential delivery is explicitly unsupported by the response resolver. Do not assume that access or refresh credentials are returned in a JSON response body.\u003C/p\u003E\n\u003Cp\u003EA redirect is a navigation decision, \u003Cstrong\u003Enot\u003C/strong\u003E an authentication or revocation mechanism. It can return a browser to the application after login or logout; the underlying security operation must stand independently of whether the browser follows the redirect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-authflowcontext-matters\u0022\u003EWhy \u003Ccode\u003EAuthFlowContext\u003C/code\u003E matters\u003C/h2\u003E\n\u003Cp\u003EWithout a shared flow context, individual endpoints tend to make their own assumptions: which tenant is active, whether a cookie represents an authenticated session, which token type is expected, whether a client should receive cookies, and where to navigate afterward.\u003C/p\u003E\n\u003Cp\u003EThose assumptions drift as applications add multiple clients, devices, tenants, or authentication modes.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead resolves these questions at the flow boundary. That provides three practical benefits:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EConsistency.\u003C/strong\u003E The same effective policy is available throughout the operation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESeparation of concerns.\u003C/strong\u003E Endpoint handlers handle HTTP; orchestrators coordinate security-relevant work; response components handle delivery.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EExtensibility.\u003C/strong\u003E New client profiles and flow behaviors can integrate with established boundaries instead of duplicating authentication plumbing.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EA context is not an authorization bypass. Possessing an \u003Ccode\u003EAuthFlowContext\u003C/code\u003E does not imply that the requested operation has been approved.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022source-of-truth-proof-versus-state\u0022\u003ESource of truth: proof versus state\u003C/h2\u003E\n\u003Cp\u003EA useful mental model is to distinguish \u003Cstrong\u003Eproof\u003C/strong\u003E, \u003Cstrong\u003Epolicy\u003C/strong\u003E, and \u003Cstrong\u003Estate\u003C/strong\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EExamples\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProof\u003C/td\u003E\n\u003Ctd\u003EPassword, session identifier, access token, refresh token\u003C/td\u003E\n\u003Ctd\u003EPresents evidence for an operation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPolicy\u003C/td\u003E\n\u003Ctd\u003EEffective mode, server options, authority decisions\u003C/td\u003E\n\u003Ctd\u003EDetermines what is permitted.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EState\u003C/td\u003E\n\u003Ctd\u003EUser eligibility, credential records, security state, session root/chain/session\u003C/td\u003E\n\u003Ctd\u003EDetermines the authoritative lifecycle and validity where applicable.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDelivery\u003C/td\u003E\n\u003Ctd\u003ECookies, supported response modes, redirect\u003C/td\u003E\n\u003Ctd\u003ECommunicates the result to the client.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA valid-looking token is not, by itself, permission to ignore revocation, tenant boundaries, session state, or mode-specific validation. Conversely, not every authentication mode is built around a persisted session hierarchy. The authoritative checks must match the configured model.\u003C/p\u003E\n\u003Cp\u003EThis distinction is central to UltimateAuth\u0027s vision: \u003Cstrong\u003Eauthentication is not the possession of a string; it is the outcome of a controlled security process.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022built-in-flows-at-a-glance\u0022\u003EBuilt-in flows at a glance\u003C/h2\u003E\n\u003Cp\u003EThe following pages explain each operation in detail. This overview focuses on their roles within the shared model.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EFlow\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003Cth\u003EPractical outcome\u003C/th\u003E\n\u003Cth\u003EGuide\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogin\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EEstablish authenticated access from acceptable credentials.\u003C/td\u003E\n\u003Ctd\u003ECreates a valid authentication session in session-issuing modes; may issue tokens according to policy.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/auth-flows/login-flow\u0022\u003ELogin Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERefresh\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContinue an existing authentication relationship using renewal authority.\u003C/td\u003E\n\u003Ctd\u003EMaintains or renews access according to mode, validation, and configured refresh behavior.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/auth-flows/refresh-flow\u0022\u003ERefresh Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd access at the applicable session or device scope.\u003C/td\u003E\n\u003Ctd\u003ETerminates authentication access and handles configured client cleanup/navigation.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/auth-flows/logout-flow\u0022\u003ELogout Flow\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese flows are related but not interchangeable. Login establishes access; refresh continues it; logout terminates it. Revocation operations provide stronger invalidation controls for the relevant security scope.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022concepts-shared-by-the-flows\u0022\u003EConcepts shared by the flows\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EWhat it contributes\u003C/th\u003E\n\u003Cth\u003EGuide\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession lifecycle\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERoot, chain, and session relationships; creation, continuity, expiry, and invalidation.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Lifecycle\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EToken behavior\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh credentials, validation, renewal, and mode-specific behavior.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Behavior\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EDevice management\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated chains, multi-device access, and targeted termination.\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-this-design-enables-in-real-applications\u0022\u003EWhat this design enables in real applications\u003C/h2\u003E\n\u003Cp\u003EConsider an application with a browser front end, a mobile client, and an administrative interface. They may share the same user identity system, but they do not necessarily have the same credential delivery needs or session behavior.\u003C/p\u003E\n\u003Cp\u003EA flow-driven architecture lets the server establish the applicable context first and then run the requested operation under that context. Developers can reason about \u003Cstrong\u003Ewhat authentication action is taking place\u003C/strong\u003E separately from \u003Cstrong\u003Ehow the resulting credentials or navigation are delivered\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also makes security-sensitive behavior easier to locate: tenant and mode resolution occur at the boundary, domain validation happens in the flow, session operations use dedicated infrastructure, and response policy is resolved independently.\u003C/p\u003E\n\u003Cp\u003EThis is the larger goal of UltimateAuth\u0027s unified model\u2014not to make every authentication operation identical, but to make their differences explicit, controlled, and composable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EAn auth flow is a controlled operation\u003C/strong\u003E, not merely an HTTP endpoint.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EAuthFlowEndpointFilter\u003C/code\u003E initializes the request context\u003C/strong\u003E for endpoints carrying flow metadata.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E resolves tenant, mode, client profile, device, identity/session information, and response policy\u003C/strong\u003E before the handler executes.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EProof is not the source of truth.\u003C/strong\u003E Validation depends on authoritative state and the effective authentication mode.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EAuthority and orchestration are distinct responsibilities.\u003C/strong\u003E The exact checks and state transitions vary by flow.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECredential delivery and redirects are outcomes\u003C/strong\u003E, not substitutes for security decisions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogin, refresh, and logout share a model without sharing identical execution paths.\u003C/strong\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003ENext step\u003C/h2\u003E\n\u003Cp\u003EStart with \u003Ca href=\u0022/docs/auth-flows/login-flow\u0022\u003ELogin Flow\u003C/a\u003E to see how UltimateAuth turns validated credentials into controlled authenticated access.\u003C/p\u003E\n", "Headings": [ { "Id": "a-flow-is-more-than-an-endpoint", @@ -84,6 +84,6 @@ "Level": 0 } ], - "SourceHash": "A2EB72E76BD9E836BEF7240D2D1FD72BF16C85F7D73C85160CFD908E689DDD4E", + "SourceHash": "9DD0D0650310BF1AD536E53D4BF6A707638ED0914712E64D469349D7E82543BB", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authentication.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authentication.json index 3b4c2c4d..8c7996d0 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authentication.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authentication.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/authentication", "Title": "Authentication", - "Html": "\n\u003Cp\u003EThe UltimateAuth Client SDK provides a high-level API for authentication workflows, including login, PKCE, session validation, refresh, logout, and device-specific session revocation.\u003C/p\u003E\n\u003Cp\u003EThese operations are exposed through \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E, which implements the \u003Ccode\u003EIFlowClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Cp\u003EThis guide shows how to use the authentication client in application code, handle structured results, configure interactive behavior, and choose the correct flow for different client environments.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-authentication-client\u0022\u003E1. Accessing the Authentication Client\u003C/h2\u003E\n\u003Cp\u003EIn a Blazor component, inject \u003Ccode\u003EIUAuthClient\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAuthentication operations are available through the \u003Ccode\u003EFlows\u003C/code\u003E property.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.RefreshAsync();\nawait UAuthClient.Flows.ValidateAsync();\nawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe relevant methods are defined by \u003Ccode\u003EIFlowClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMethod\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmit a direct login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAttempt login with configurable try/commit behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginPkceAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStart a PKCE authentication flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EComplete a PKCE login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryCompletePkceLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EComplete PKCE with structured try/commit behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh or continue authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidate current authentication state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELog out the current session\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutMyDeviceAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a selected device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutMyOtherDevicesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke other devices while retaining the current one\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutAllMyDevicesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all devices for the current user\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative device logout operations are also available and covered later in this guide.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-login-flow\u0022\u003E2. Choosing the Correct Login Flow\u003C/h2\u003E\n\u003Cp\u003EBefore implementing login, determine whether your application is a trusted server-hosted client or a public client.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient environment\u003C/th\u003E\n\u003Cth\u003ERecommended flow\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003EDirect login or supported interactive login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003EServer-hosted authentication flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WebAssembly\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBrowser SPA\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPublic native application\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPublic clients must not rely on embedding a secret or trusting browser-executed code to protect credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth restricts direct credential posting based on the configured client profile and login options.\u003C/p\u003E\n\u003Cp\u003EFor public clients, use the PKCE flow instead of enabling direct credential posting.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022direct-login\u0022\u003E3. Direct Login\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ELoginAsync\u003C/code\u003E performs a direct login submission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LoginRequest\n{\n Identifier = \u0026quot;user@example.com\u0026quot;,\n Secret = \u0026quot;password\u0026quot;\n};\n\nawait UAuthClient.Flows.LoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003ETask\u003C/code\u003E, not a structured login result.\u003C/p\u003E\n\u003Cp\u003EIt submits the login payload through the configured navigation-based transport.\u003C/p\u003E\n\u003Cp\u003EDepending on the hosting environment, this may initiate navigation or redirect behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022loginrequest-properties\u0022\u003ELoginRequest Properties\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ELoginRequest\u003C/code\u003E supports the following properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type; defaults to \u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional request metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequestTokens\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHint to request tokens when supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviewReceipt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional receipt associated with a prior login preview\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EExample:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LoginRequest\n{\n Identifier = email,\n Secret = password,\n CredentialType = CredentialType.Password,\n RequestTokens = true\n};\n\nawait UAuthClient.Flows.LoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERequestTokens\u003C/code\u003E is a hint, not a guarantee that tokens will be issued. The server\u0027s authentication mode and policy remain authoritative.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-after-login\u0022\u003ERedirect After Login\u003C/h3\u003E\n\u003Cp\u003EProvide an explicit return URL when necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LoginAsync(request, returnUrl: \u0026quot;/dashboard\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe direct login flow resolves its return URL in the following order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe explicit \u003Ccode\u003EreturnUrl\u003C/code\u003E argument.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ELogin.ReturnUrl\u003C/code\u003E option.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDefaultReturnUrl\u003C/code\u003E option.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor example, client options can specify a default:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.ReturnUrl = \u0026quot;/dashboard\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe return URL is part of the login workflow. It should not be treated as an authorization grant.\u003C/p\u003E\n\u003Cp\u003EApplications and servers must still enforce their redirect validation rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-login-with-tryloginasync\u0022\u003E4. Interactive Login with TryLoginAsync\u003C/h2\u003E\n\u003Cp\u003EFor interactive forms, \u003Ccode\u003ETryLoginAsync\u003C/code\u003E provides structured feedback before or during the authentication commit process.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryAndCommit);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is generally the more useful API for a login form that needs to display validation feedback.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submit-modes\u0022\u003ESubmit Modes\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthSubmitMode\u003C/code\u003E defines three submission behaviors.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExecute the try operation and return its result without committing authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmit directly to the commit endpoint without a separate try operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETry first, then commit when the try operation succeeds\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese modes control client workflow, not whether the server validates authentication.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDirectCommit does not bypass credential verification or server security requirements.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tryonly-validate-without-login\u0022\u003ETryOnly: Validate Without Login\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryOnly\u003C/code\u003E when you want structured feedback without establishing an authenticated session.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryOnly);\n\nif (result.IsSuccess)\n{\n // The try operation succeeded.\n // Authentication has not been committed.\n}\nelse\n{\n // Display the appropriate feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis can be useful for a custom interactive workflow.\u003C/p\u003E\n\u003Cp\u003EA successful try result must not be interpreted as an authenticated application session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tryandcommit-interactive-login\u0022\u003ETryAndCommit: Interactive Login\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryAndCommit\u003C/code\u003E to combine structured validation with authentication commit.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryAndCommit,\n returnUrl: \u0026quot;/dashboard\u0026quot;);\n\nif (!result.IsSuccess)\n{\n // Show validation feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen the try stage succeeds, the client proceeds to the commit operation through its configured transport.\u003C/p\u003E\n\u003Cp\u003EThis can involve navigation, so application code should not assume that execution always continues within the same component instance.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022directcommit-single-commit-submission\u0022\u003EDirectCommit: Single Commit Submission\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.DirectCommit);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIn this mode, the SDK submits the commit operation directly.\u003C/p\u003E\n\u003Cp\u003EThe method returns a \u003Ccode\u003ETryLoginResult\u003C/code\u003E with \u003Ccode\u003EIsSuccess = true\u003C/code\u003E after the navigation submission completes, but this is not a structured server-side authentication success confirmation.\u003C/p\u003E\n\u003Cp\u003EIf you need structured credential validation feedback, prefer \u003Ccode\u003ETryAndCommit\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-login-results\u0022\u003E5. Handling Login Results\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ETryLoginAsync\u003C/code\u003E returns \u003Ccode\u003ETryLoginResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts available properties include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the try operation succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional authentication failure reason\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemainingAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERemaining attempts, when provided\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutUntilUtc\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELockout expiration, when provided\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresMfa\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether additional authentication is required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviewReceipt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional receipt for subsequent flow handling\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EExample:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryOnly);\n\nif (!result.IsSuccess)\n{\n if (result.LockoutUntilUtc is not null)\n {\n errorMessage = $\u0026quot;Login temporarily unavailable until {result.LockoutUntilUtc:u}\u0026quot;;\n }\n else\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n\n return;\n}\n\nif (result.RequiresMfa)\n{\n // Continue with the supported additional authentication flow.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not expose sensitive internal authentication details unnecessarily.\u003C/p\u003E\n\u003Cp\u003EFor public-facing login forms, generic failure messages may be preferable to displaying every failure reason.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-transport-and-protocol-errors\u0022\u003EHandling Transport and Protocol Errors\u003C/h3\u003E\n\u003Cp\u003EA structured authentication failure is different from a network or protocol failure.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Etry\n{\n var result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryAndCommit);\n\n if (!result.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n}\ncatch (UAuthTransportException)\n{\n errorMessage = \u0026quot;Authentication service is unavailable.\u0026quot;;\n}\ncatch (UAuthProtocolException)\n{\n errorMessage = \u0026quot;An authentication protocol error occurred.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the corresponding UltimateAuth client error namespace when referencing these exception types.\u003C/p\u003E\n\u003Cp\u003EA failed authentication attempt should normally be handled through the structured try result where supported. Transport and protocol exceptions indicate different failure categories.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-authentication\u0022\u003E6. PKCE Authentication\u003C/h2\u003E\n\u003Cp\u003EPKCE provides a proof mechanism for authorization-code-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports PKCE using an S256 code challenge.\u003C/p\u003E\n\u003Cp\u003EThe SDK generates a cryptographically random verifier, derives its challenge, and coordinates the authorization flow.\u003C/p\u003E\n\u003Cp\u003EPublic clients such as Blazor WebAssembly should use PKCE rather than direct credential posting.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022start-pkce\u0022\u003EStart PKCE\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.BeginPkceAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can provide a return URL:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.BeginPkceAsync(returnUrl: \u0026quot;/dashboard\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client performs the following operations:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EGenerates a PKCE code verifier.\u003C/li\u003E\n\u003Cli\u003EDerives an S256 code challenge.\u003C/li\u003E\n\u003Cli\u003ESends the authorization request.\u003C/li\u003E\n\u003Cli\u003EReceives an authorization code.\u003C/li\u003E\n\u003Cli\u003EInvokes the configured authorization callback, if any.\u003C/li\u003E\n\u003Cli\u003ENavigates to the configured UAuthHub login flow when automatic redirection is enabled.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe authorization code and verifier are used later to complete the flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-pkce\u0022\u003EConfigure PKCE\u003C/h3\u003E\n\u003Cp\u003EThe relevant client options include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.Enabled = true;\noptions.Pkce.AutoRedirect = true;\noptions.Pkce.ReturnUrl = \u0026quot;/dashboard\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is enabled by default in \u003Ccode\u003EUAuthClientPkceLoginFlowOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAutomatic redirection is also enabled by default.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022observe-authorization\u0022\u003EObserve Authorization\u003C/h3\u003E\n\u003Cp\u003EYou can register a callback that runs after the authorization code is received and before the automatic Hub redirect.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.OnAuthorized = async response =\u0026gt;\n{\n // Observe the authorization response.\n await Task.CompletedTask;\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis callback is useful for application-specific coordination.\u003C/p\u003E\n\u003Cp\u003EDo not log authorization codes or expose them to unrelated components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirection\u0022\u003EDisable Automatic Redirection\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.AutoRedirect = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith this configuration, \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E still performs the authorization request and invokes \u003Ccode\u003EOnAuthorized\u003C/code\u003E, but it does not automatically navigate to the Hub login endpoint.\u003C/p\u003E\n\u003Cp\u003EThe application becomes responsible for coordinating the remaining flow.\u003C/p\u003E\n\u003Cp\u003EThis is an advanced integration option.\u003C/p\u003E\n\u003Cp\u003EA custom implementation must preserve the authorization code, verifier, and relevant context securely and complete the flow using the supported protocol.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-pkce-login\u0022\u003E7. Complete PKCE Login\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E submits the PKCE completion request.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new PkceCompleteRequest\n{\n AuthorizationCode = authorizationCode,\n CodeVerifier = codeVerifier,\n Identifier = email,\n Secret = password,\n ReturnUrl = \u0026quot;/dashboard\u0026quot;\n};\n\nawait UAuthClient.Flows.CompletePkceLoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECode obtained from the authorization step\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeVerifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOriginal PKCE verifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional post-login destination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional Hub session context\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization code and verifier must correspond to the same PKCE flow.\u003C/p\u003E\n\u003Cp\u003EDo not generate a new verifier when completing an existing authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-pkce-completion\u0022\u003EInteractive PKCE Completion\u003C/h3\u003E\n\u003Cp\u003EFor structured feedback, use \u003Ccode\u003ETryCompletePkceLoginAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryCompletePkceLoginAsync(\n request,\n UAuthSubmitMode.TryAndCommit);\n\nif (!result.IsSuccess)\n{\n // Display PKCE login feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same submit modes are supported:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022try-pkce-without-commit\u0022\u003ETry PKCE Without Commit\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryOnly);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis returns a \u003Ccode\u003ETryPkceLoginResult\u003C/code\u003E without committing a successful login.\u003C/p\u003E\n\u003Cp\u003EThe result includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eresult.IsSuccess;\nresult.Reason;\nresult.RemainingAttempts;\nresult.LockoutUntilUtc;\nresult.RequiresMfa;\nresult.RetryWithNewPkce;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrywithnewpkce\u0022\u003ERetryWithNewPkce\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERetryWithNewPkce\u003C/code\u003E indicates that the client should begin a new PKCE authorization sequence rather than continuing with the existing authorization material.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.RetryWithNewPkce)\n{\n await UAuthClient.Flows.BeginPkceAsync(\u0026quot;/login\u0026quot;);\n return;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when the current PKCE context can no longer be used.\u003C/p\u003E\n\u003Cp\u003EThe application should not repeatedly resubmit the same invalid authorization code and verifier.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022direct-credential-posting-restrictions\u0022\u003E8. Direct Credential Posting Restrictions\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth restricts direct credential posting according to the configured client profile.\u003C/p\u003E\n\u003Cp\u003EThe client option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.AllowCredentialPost = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is the default.\u003C/p\u003E\n\u003Cp\u003EIf the current profile is not permitted to post credentials directly and the option has not enabled that capability, direct login methods throw an \u003Ccode\u003EInvalidOperationException\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, attempting direct login from a public client may produce an error indicating that PKCE is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022trusted-server-hosted-clients\u0022\u003ETrusted Server-Hosted Clients\u003C/h3\u003E\n\u003Cp\u003EThe option can be explicitly configured for a trusted server-hosted integration where direct credential posting is appropriate:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.AllowCredentialPost = true;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis must not be used to bypass PKCE requirements in a public browser application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable direct credential posting in production Blazor WebAssembly or SPA clients.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EA client profile is an integration descriptor, not cryptographic proof that the caller is trusted.\u003C/p\u003E\n\u003Cp\u003EServer-side security requirements remain authoritative.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-authentication\u0022\u003E9. Refresh Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E requests continuation of the current authentication state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003ERefreshResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RefreshResult\n{\n public bool IsSuccess { get; init; }\n public int Status { get; init; }\n public RefreshOutcome Outcome { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERefresh behavior depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EA refresh operation may update session activity, rotate authentication material, or determine that reauthentication is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-refresh-results\u0022\u003EHandling Refresh Results\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\nif (result.IsSuccess)\n{\n // Authentication continuation succeeded.\n}\nelse if (result.Outcome == RefreshOutcome.ReauthRequired)\n{\n // The current authentication can no longer continue.\n // Direct the user to the appropriate login flow.\n}\nelse\n{\n // Handle another unsuccessful refresh result.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA \u003Ccode\u003E401 Unauthorized\u003C/code\u003E refresh response is handled as a normal reauthentication outcome.\u003C/p\u003E\n\u003Cp\u003EIt returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Enew RefreshResult\n{\n IsSuccess = false,\n Status = 401,\n Outcome = RefreshOutcome.ReauthRequired\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENetwork and unexpected protocol failures can still produce exceptions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-outcomes\u0022\u003ERefresh Outcomes\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshOutcome\u003C/code\u003E defines the following values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOutcome\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo specific semantic outcome\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EGeneric successful refresh\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENoOp\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo refresh state change was necessary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouched\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EApplicable session activity was updated\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERotation occurred\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReauthRequired\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication continuation is no longer permitted\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\nif (!result.IsSuccess)\n{\n return;\n}\n\nswitch (result.Outcome)\n{\n case RefreshOutcome.Rotated:\n // Authentication material was rotated.\n break;\n\n case RefreshOutcome.Touched:\n // Applicable session activity was updated.\n break;\n\n case RefreshOutcome.NoOp:\n // Successful refresh and no state change was required.\n break;\n\n case RefreshOutcome.Success:\n // Refresh succeeded without a more specific outcome.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detailed-refresh-diagnostics\u0022\u003EDetailed Refresh Diagnostics\u003C/h3\u003E\n\u003Cp\u003EThe server may return a generic success outcome rather than exposing the internal refresh action.\u003C/p\u003E\n\u003Cp\u003EWhere supported, detailed refresh outcomes can be enabled through server diagnostics:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is intended for diagnostics and observability.\u003C/p\u003E\n\u003Cp\u003EApplications should not require a specific successful outcome such as \u003Ccode\u003ERotated\u003C/code\u003E or \u003Ccode\u003ETouched\u003C/code\u003E to consider refresh successful.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsSuccess\u003C/code\u003E for the primary success decision.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022manual-and-automatic-refresh\u0022\u003EManual and Automatic Refresh\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E has an optional \u003Ccode\u003EisAuto\u003C/code\u003E argument.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E// Manual refresh\nawait UAuthClient.Flows.RefreshAsync();\n\n// Refresh initiated by automatic coordination\nawait UAuthClient.Flows.RefreshAsync(isAuto: true);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe parameter distinguishes automatic refresh activity for client diagnostics.\u003C/p\u003E\n\u003Cp\u003EIt does not select a different authentication mode or override the server\u0027s refresh policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022automatic-refresh-configuration\u0022\u003E10. Automatic Refresh Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes client-side automatic refresh options through \u003Ccode\u003EAutoRefresh\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AutoRefresh.Enabled = true;\noptions.AutoRefresh.Interval = TimeSpan.FromMinutes(5);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEnabled\u003C/code\u003E controls background refresh coordination.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EInterval\u003C/code\u003E specifies the interval used for background refresh attempts.\u003C/p\u003E\n\u003Cp\u003EThe effective defaults and registration behavior depend on the client integration. The options documentation identifies Blazor Server as the environment where background refresh is enabled by default.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022automatic-refresh-is-not-a-security-guarantee\u0022\u003EAutomatic Refresh Is Not a Security Guarantee\u003C/h3\u003E\n\u003Cp\u003EAutomatic refresh is a client-side convenience mechanism.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that a session will remain valid indefinitely.\u003C/p\u003E\n\u003Cp\u003EAuthentication may still end because of:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EExpiration.\u003C/li\u003E\n\u003Cli\u003ERevocation.\u003C/li\u003E\n\u003Cli\u003EAccount security changes.\u003C/li\u003E\n\u003Cli\u003EInvalid refresh credentials.\u003C/li\u003E\n\u003Cli\u003EServer-side continuation restrictions.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe client must remain prepared to handle \u003Ccode\u003EReauthRequired\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validate-current-authentication\u0022\u003E11. Validate Current Authentication\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EValidateAsync\u003C/code\u003E to ask the server to validate the current authentication state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.ValidateAsync();\n\nif (result.IsValid)\n{\n // Current authentication is valid.\n}\nelse\n{\n // Authentication is not currently valid.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EAuthValidationResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts properties include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsValid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the state is \u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESnapshot\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional authentication state snapshot\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional associated session chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERootId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional session root\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBoundDeviceId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional bound device identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-context\u0022\u003EInspect Session Context\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.ValidateAsync();\n\nif (result.IsValid \u0026amp;\u0026amp; result.ChainId is { } chainId)\n{\n // Use chainId for supported session-management operations.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENot every authentication mode necessarily provides all session hierarchy fields.\u003C/p\u003E\n\u003Cp\u003ETreat optional values as optional.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validateasync-versus-ui-authentication-state\u0022\u003EValidateAsync Versus UI Authentication State\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E performs an explicit server validation operation.\u003C/p\u003E\n\u003Cp\u003EIt should not be called on every UI render merely to determine whether a component should display authenticated content.\u003C/p\u003E\n\u003Cp\u003EFor UI authentication state, use the supported UltimateAuth state management integration, including \u003Ccode\u003EIUAuthStateManager\u003C/code\u003E where appropriate.\u003C/p\u003E\n\u003Cp\u003EExplicit validation is useful when an application needs to check current server-recognized authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003E12. Logout\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ELogoutAsync\u003C/code\u003E performs the current logout flow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method uses the configured logout endpoint and navigation transport.\u003C/p\u003E\n\u003Cp\u003EDepending on the client profile and configuration, logout may involve navigation and authentication state changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-button\u0022\u003ELogout Button\u003C/h3\u003E\n\u003Cp\u003EA simple Blazor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\n\u0026lt;button @onclick=\u0026quot;LogoutAsync\u0026quot;\u0026gt;Logout\u0026lt;/button\u0026gt;\n\n@code {\n private async Task LogoutAsync()\n {\n await UAuthClient.Flows.LogoutAsync();\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELogout is distinct from clearing local UI state.\u003C/p\u003E\n\u003Cp\u003ERemoving a local authentication indicator does not necessarily revoke authoritative server-managed authentication state.\u003C/p\u003E\n\u003Cp\u003EUse the supported logout flow to terminate authentication.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-specific-device\u0022\u003E13. Logout a Specific Device\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports revoking the session chain associated with a particular device context.\u003C/p\u003E\n\u003Cp\u003EThe relevant method is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ELogoutMyDeviceAsync(LogoutDeviceRequest request)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E requires a \u003Ccode\u003ESessionChainId\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutDeviceRequest\n{\n ChainId = chainId\n};\n\nvar result = await UAuthClient.Flows.LogoutMyDeviceAsync(request);\n\nif (result.IsSuccess)\n{\n // The requested device logout operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation targets a session chain, not an arbitrary individual session ID.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022current-device-behavior\u0022\u003ECurrent Device Behavior\u003C/h3\u003E\n\u003Cp\u003EThe returned \u003Ccode\u003ERevokeResult\u003C/code\u003E can indicate whether the affected chain is the current chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the client publishes a logout-related state event.\u003C/p\u003E\n\u003Cp\u003EThis allows the application to react when the currently authenticated device context has been revoked.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-other-devices\u0022\u003E14. Logout Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ELogoutMyOtherDevicesAsync\u003C/code\u003E to revoke other device-associated sessions while retaining the current one.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync();\n\nif (result.IsSuccess)\n{\n // Other devices were logged out.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for account security pages that provide a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action.\u003C/p\u003E\n\u003Cp\u003EThe current authentication context is intended to remain active.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for identifying the current session or chain and enforcing the appropriate revocation scope.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-all-devices\u0022\u003E15. Logout All Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ELogoutAllMyDevicesAsync\u003C/code\u003E to revoke all applicable sessions for the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutAllMyDevicesAsync();\n\nif (result.IsSuccess)\n{\n // All applicable device sessions were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOn success, the client publishes a logout-related state event.\u003C/p\u003E\n\u003Cp\u003EThis operation is useful when a user suspects account compromise or wants to terminate existing authentication sessions across devices.\u003C/p\u003E\n\u003Cp\u003EIn JWT-based modes, previously issued access tokens may remain cryptographically valid until expiration unless additional revocation enforcement is configured.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-device-logout\u0022\u003E16. Administrative Device Logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth also exposes administrative session revocation operations.\u003C/p\u003E\n\u003Cp\u003EThese methods act on a specified user and require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-one-user-device\u0022\u003ELogout One User Device\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutUserDeviceAsync(\n userKey,\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\nif (!result.IsSuccess)\n{\n // Handle the failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-other-devices-for-a-user\u0022\u003ELogout Other Devices for a User\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutUserOtherDevicesAsync(userKey, request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHere, \u003Ccode\u003Erequest\u003C/code\u003E is a \u003Ccode\u003ELogoutOtherDevicesRequest\u003C/code\u003E identifying the chain to retain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-all-devices-for-a-user\u0022\u003ELogout All Devices for a User\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(userKey);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative methods do not grant administrative privileges by themselves.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize the actor and validate the target user and requested revocation scope.\u003C/p\u003E\n\u003Cp\u003EThese operations are particularly relevant to administration panels and account security workflows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022building-login-forms-with-uauthloginform\u0022\u003E17. Building Login Forms with UAuthLoginForm\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E, a reusable Blazor component that handles authentication form submission without requiring developers to implement the underlying flow orchestration themselves.\u003C/p\u003E\n\u003Cp\u003EThe component supports password-based login, PKCE completion through UAuthHub, configurable submission modes, structured validation feedback, and customizable UI content.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EYou design the login interface. UltimateAuth handles the authentication submission workflow.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic-usage\u0022\u003EBasic Usage\u003C/h3\u003E\n\u003Cp\u003EA login form can be implemented with the following component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;Password\u0026quot; /\u0026gt;\n \u0026lt;button type=\u0026quot;submit\u0026quot;\u0026gt;Sign in\u0026lt;/button\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe component automatically uses \u003Ccode\u003ETryAndCommit\u003C/code\u003E as its default submission mode.\u003C/p\u003E\n\u003Cp\u003EWhen submitted, it:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EValidates that the identifier and secret are present.\u003C/li\u003E\n\u003Cli\u003ECreates the appropriate authentication request.\u003C/li\u003E\n\u003Cli\u003ECalls the UltimateAuth client flow.\u003C/li\u003E\n\u003Cli\u003EPerforms the configured try/commit behavior.\u003C/li\u003E\n\u003Cli\u003EExposes structured results through \u003Ccode\u003EOnTryResult\u003C/code\u003E.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe example assumes that UltimateAuth\u0027s Blazor services have already been registered and that the hosting environment permits the selected login flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customizing-the-login-ui\u0022\u003ECustomizing the Login UI\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthLoginForm\u003C/code\u003E uses \u003Ccode\u003EChildContent\u003C/code\u003E, allowing developers to use standard HTML elements or their preferred Blazor component library.\u003C/p\u003E\n\u003Cp\u003EFor example, with MudBlazor:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n@using CodeBeam.UltimateAuth.Client\n\n\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot; OnTryResult=\u0026quot;HandleLoginResult\u0026quot;\u0026gt;\n \u0026lt;MudStack Spacing=\u0026quot;3\u0026quot;\u0026gt;\n \u0026lt;MudText Typo=\u0026quot;Typo.h5\u0026quot;\u0026gt;Sign in\u0026lt;/MudText\u0026gt;\n \u0026lt;MudTextField @bind-Value=\u0026quot;email\u0026quot; Label=\u0026quot;Email\u0026quot; Variant=\u0026quot;Variant.Outlined\u0026quot; /\u0026gt;\n \u0026lt;MudTextField @bind-Value=\u0026quot;password\u0026quot; Label=\u0026quot;Password\u0026quot; InputType=\u0026quot;InputType.Password\u0026quot; Variant=\u0026quot;Variant.Outlined\u0026quot; /\u0026gt;\n\n \u0026lt;MudButton ButtonType=\u0026quot;ButtonType.Submit\u0026quot; Variant=\u0026quot;Variant.Filled\u0026quot; Color=\u0026quot;Color.Primary\u0026quot; FullWidth\u0026gt;Sign in\u0026lt;/MudButton\u0026gt;\n\n @if (!string.IsNullOrWhiteSpace(error))\n {\n \u0026lt;MudAlert Severity=\u0026quot;Severity.Error\u0026quot;\u0026gt;@error\u0026lt;/MudAlert\u0026gt;\n }\n \u0026lt;/MudStack\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n private string? error;\n\n private Task HandleLoginResult(IUAuthTryResult result)\n {\n error = result.IsSuccess ? null : \u0026quot;Unable to sign in.\u0026quot;;\n return Task.CompletedTask;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe form controls remain under your control.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not require a particular visual design, CSS framework, or component library.\u003C/p\u003E\n\u003Cp\u003EBecause the component renders an HTML form internally, place the submit button inside its child content and avoid nesting another HTML form within it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submission-modes\u0022\u003ESubmission Modes\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESubmitMode\u003C/code\u003E to control how the authentication attempt is executed.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidates the authentication attempt without committing login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAttempts authentication and commits on success\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmits directly to the commit flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E is the default.\u003C/p\u003E\n\u003Cp\u003EFor interactive login forms that need validation feedback, it is generally the recommended choice.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E does not invoke \u003Ccode\u003EOnTryResult\u003C/code\u003E, because it does not perform the structured try operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-authentication-results\u0022\u003EHandling Authentication Results\u003C/h3\u003E\n\u003Cp\u003EThe component exposes an \u003Ccode\u003EOnTryResult\u003C/code\u003E callback:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; OnTryResult=\u0026quot;HandleResult\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe callback receives \u003Ccode\u003EIUAuthTryResult\u003C/code\u003E, allowing the same component to report password login and PKCE completion results.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eprivate Task HandleResult(IUAuthTryResult result)\n{\n if (!result.IsSuccess)\n {\n errorMessage = \u0026quot;Authentication failed.\u0026quot;;\n }\n\n return Task.CompletedTask;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor flow-specific details, inspect the concrete result type:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eprivate Task HandleResult(IUAuthTryResult result)\n{\n if (result is TryLoginResult loginResult)\n {\n if (loginResult.LockoutUntilUtc is not null)\n {\n errorMessage = \u0026quot;Your account is temporarily locked.\u0026quot;;\n }\n else if (loginResult.RequiresMfa)\n {\n // Continue the supported additional authentication workflow.\n }\n else if (!loginResult.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n }\n else if (result is TryPkceLoginResult pkceResult)\n {\n if (pkceResult.RetryWithNewPkce)\n {\n errorMessage = \u0026quot;Please restart the authentication flow.\u0026quot;;\n }\n else if (!pkceResult.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to complete authentication.\u0026quot;;\n }\n }\n\n return Task.CompletedTask;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe callback reports the structured try result. It does not replace authentication state management or server-side validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-uauthloginform-with-pkce\u0022\u003EUsing UAuthLoginForm with PKCE\u003C/h3\u003E\n\u003Cp\u003EThe same component also supports PKCE login completion when hosted within a supported UAuthHub environment.\u003C/p\u003E\n\u003Cp\u003ESet \u003Ccode\u003ELoginType\u003C/code\u003E to \u003Ccode\u003EPkce\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm LoginType=\u0026quot;UAuthLoginType.Pkce\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot; OnTryResult=\u0026quot;HandleLoginResult\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;Password\u0026quot; /\u0026gt;\n \u0026lt;button type=\u0026quot;submit\u0026quot;\u0026gt;Continue\u0026lt;/button\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIn PKCE mode, the component:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EResolves the relevant UAuthHub session context.\u003C/li\u003E\n\u003Cli\u003ERetrieves the authorization code and code verifier through the configured Hub services.\u003C/li\u003E\n\u003Cli\u003EBuilds a \u003Ccode\u003EPkceCompleteRequest\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EInvokes the corresponding PKCE completion flow.\u003C/li\u003E\n\u003Cli\u003EReports the structured result when a try-enabled submission mode is used.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe application does not need to manually construct the PKCE completion request for this form.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E PKCE mode requires a supported server-side UAuthHub environment. \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E checks the available Hub capabilities and throws when PKCE login is requested in an unsupported client profile.\u003C/p\u003E\n\u003Cp\u003EA Blazor WebAssembly application should initiate PKCE using \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E; the Hub-hosted login form handles the completion stage.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022return-url-handling\u0022\u003EReturn URL Handling\u003C/h3\u003E\n\u003Cp\u003EYou can provide an explicit return URL:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; ReturnUrl=\u0026quot;/dashboard\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe component resolves the effective return URL using the following precedence:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003EReturnUrl\u003C/code\u003E parameter.\u003C/li\u003E\n\u003Cli\u003EUAuthHub flow return URL when using PKCE.\u003C/li\u003E\n\u003Cli\u003ECurrent navigation URL.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThis allows a shared login component to participate in different authentication journeys without hardcoding navigation logic into the form.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-endpoints\u0022\u003ECustom Login Endpoints\u003C/h3\u003E\n\u003Cp\u003EFor advanced integrations, the \u003Ccode\u003EEndpoint\u003C/code\u003E parameter allows the form\u0027s HTML action endpoint to be overridden:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; Endpoint=\u0026quot;/custom-login\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.DirectCommit\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe endpoint is resolved relative to the configured UltimateAuth endpoint base path and multi-tenant settings.\u003C/p\u003E\n\u003Cp\u003ENote that \u003Ccode\u003EEndpoint\u003C/code\u003E affects the form\u0027s HTML action. Try-based submissions invoke the configured \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E endpoints, so changing \u003Ccode\u003EEndpoint\u003C/code\u003E alone does not redirect those SDK requests to a custom endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submit-the-form-programmatically\u0022\u003ESubmit the Form Programmatically\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthLoginForm\u003C/code\u003E exposes \u003Ccode\u003ESubmitAsync\u003C/code\u003E, allowing submission through a component reference.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm @ref=\u0026quot;loginForm\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; AllowEnterKeyToSubmit=\u0026quot;false\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; /\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;SubmitLoginAsync\u0026quot;\u0026gt;\n Sign in\n\u0026lt;/button\u0026gt;\n\n@code {\n private UAuthLoginForm? loginForm;\n\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n\n private async Task SubmitLoginAsync()\n {\n if (loginForm is not null)\n {\n await loginForm.SubmitAsync();\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ESubmitAsync\u003C/code\u003E must be called after the component has rendered.\u003C/p\u003E\n\u003Cp\u003EThis is useful when submission is initiated from a custom button, dialog, or application-specific interaction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022controlling-enter-key-submission\u0022\u003EControlling Enter-Key Submission\u003C/h3\u003E\n\u003Cp\u003EBy default, the component supports Enter-key submission.\u003C/p\u003E\n\u003Cp\u003EYou can disable its built-in hidden submit button:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; AllowEnterKeyToSubmit=\u0026quot;false\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls the component\u0027s built-in Enter-key submit mechanism. Other submit buttons inside the form may still trigger normal browser form submission behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reloading-pkce-context\u0022\u003EReloading PKCE Context\u003C/h3\u003E\n\u003Cp\u003EThe component exposes \u003Ccode\u003EReloadAsync\u003C/code\u003E to reload its Hub credentials and flow state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm @ref=\u0026quot;loginForm\u0026quot; LoginType=\u0026quot;UAuthLoginType.Pkce\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private UAuthLoginForm? loginForm;\n\n private async Task ReloadLoginContextAsync()\n {\n if (loginForm is not null)\n {\n await loginForm.ReloadAsync();\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an advanced capability intended for scenarios where the associated Hub state changes and the component must refresh its internal context.\u003C/p\u003E\n\u003Cp\u003EIt does not initiate a new PKCE authorization flow by itself.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022component-parameters\u0022\u003EComponent Parameters\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EParameter\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassword or PKCE login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESubmitMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSubmitMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmission strategy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERedirect destination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustom HTML form endpoint\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExplicit Hub session context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowEnterKeyToSubmit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBuilt-in Enter-key submit support\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnTryResult\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EEventCallback\u0026lt;IUAuthTryResult\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u2014\u003C/td\u003E\n\u003Ctd\u003EStructured try result callback\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChildContent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERenderFragment?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u2014\u003C/td\u003E\n\u003Ctd\u003ECustom form content\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022when-to-use-uauthloginform\u0022\u003EWhen to Use UAuthLoginForm\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E when building a Blazor login interface that should integrate directly with UltimateAuth\u0027s supported authentication flows.\u003C/p\u003E\n\u003Cp\u003EIt is particularly useful when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou want to use your own component library without implementing the authentication submission logic.\u003C/li\u003E\n\u003Cli\u003EYou need structured login validation feedback.\u003C/li\u003E\n\u003Cli\u003EYou want to switch between try-only and commit behavior.\u003C/li\u003E\n\u003Cli\u003EYou are building a UAuthHub login page that completes PKCE.\u003C/li\u003E\n\u003Cli\u003EYou want to share login UI across supported Blazor hosting environments.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor application-specific workflows that cannot be expressed through the component, use \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E directly.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe component handles the login workflow, while your application retains control over presentation, user experience, and surrounding application logic.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-integration-mistakes\u0022\u003E18. Common Integration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-direct-login-from-a-public-client\u0022\u003ECalling Direct Login from a Public Client\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E \u003Ccode\u003ELoginAsync\u003C/code\u003E throws because direct credential posting is disabled.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Use \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E and the supported PKCE completion flow. Do not enable \u003Ccode\u003EAllowCredentialPost\u003C/code\u003E to bypass the public-client restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-tryonly-as-authentication\u0022\u003ETreating TryOnly as Authentication\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E The application receives \u003Ccode\u003EIsSuccess = true\u003C/code\u003E from \u003Ccode\u003ETryOnly\u003C/code\u003E and assumes the user is logged in.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E A successful try result does not commit authentication. Use \u003Ccode\u003ETryAndCommit\u003C/code\u003E or the appropriate commit flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-directcommit-skips-server-validation\u0022\u003EAssuming DirectCommit Skips Server Validation\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E DirectCommit is interpreted as a faster but less secure authentication mode.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E DirectCommit skips the separate client try stage. The server still validates the actual authentication request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-every-refresh-rotates-tokens\u0022\u003EAssuming Every Refresh Rotates Tokens\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E The application requires \u003Ccode\u003ERefreshOutcome.Rotated\u003C/code\u003E for refresh success.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Check \u003Ccode\u003ERefreshResult.IsSuccess\u003C/code\u003E. Rotation, touch, and no-op behavior depend on the authentication mode and refresh strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-a-session-id-for-device-logout\u0022\u003EUsing a Session ID for Device Logout\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E A session identifier is passed to a device logout operation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E \u003Ccode\u003ELogoutMyDeviceAsync\u003C/code\u003E requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E with a \u003Ccode\u003ESessionChainId\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-logout-immediately-invalidates-all-jwts\u0022\u003EAssuming Logout Immediately Invalidates All JWTs\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E An application expects every issued JWT access token to become unusable immediately after server-side logout.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Understand the enforcement behavior of SemiHybrid and PureJwt. Local JWT validation may continue accepting previously issued tokens until expiration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-validateasync-on-every-render\u0022\u003EUsing ValidateAsync on Every Render\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E Components repeatedly make validation requests just to display authenticated UI.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Use the appropriate authentication state integration for UI rendering, and reserve explicit validation for operations that require it.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and manage session and device information.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validity, refresh behavior, and revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/refresh-rotation\u0022\u003ERefresh Rotation \u0026amp; Replay Protection\u003C/a\u003E \u2014 Explore refresh concurrency and reuse security.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E \u2014 Compare authentication behavior across modes.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Understand device-associated session chains.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EThe UltimateAuth Client SDK provides a high-level API for authentication workflows, including login, PKCE, session validation, refresh, logout, and device-specific session revocation.\u003C/p\u003E\n\u003Cp\u003EThese operations are exposed through \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E, which implements the \u003Ccode\u003EIFlowClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Cp\u003EThis guide shows how to use the authentication client in application code, handle structured results, configure interactive behavior, and choose the correct flow for different client environments.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-authentication-client\u0022\u003E1. Accessing the Authentication Client\u003C/h2\u003E\n\u003Cp\u003EIn a Blazor component, inject \u003Ccode\u003EIUAuthClient\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAuthentication operations are available through the \u003Ccode\u003EFlows\u003C/code\u003E property.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.RefreshAsync();\nawait UAuthClient.Flows.ValidateAsync();\nawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe relevant methods are defined by \u003Ccode\u003EIFlowClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMethod\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmit a direct login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAttempt login with configurable try/commit behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginPkceAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStart a PKCE authentication flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EComplete a PKCE login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryCompletePkceLoginAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EComplete PKCE with structured try/commit behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh or continue authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidate current authentication state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELog out the current session\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutMyDeviceAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a selected device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutMyOtherDevicesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke other devices while retaining the current one\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogoutAllMyDevicesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all devices for the current user\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative device logout operations are also available and covered later in this guide.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-login-flow\u0022\u003E2. Choosing the Correct Login Flow\u003C/h2\u003E\n\u003Cp\u003EBefore implementing login, determine whether your application is a trusted server-hosted client or a public client.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient environment\u003C/th\u003E\n\u003Cth\u003ERecommended flow\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003EDirect login or supported interactive login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003EServer-hosted authentication flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WebAssembly\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBrowser SPA\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPublic native application\u003C/td\u003E\n\u003Ctd\u003EPKCE\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPublic clients must not rely on embedding a secret or trusting browser-executed code to protect credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth restricts direct credential posting based on the configured client profile and login options.\u003C/p\u003E\n\u003Cp\u003EFor public clients, use the PKCE flow instead of enabling direct credential posting.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022direct-login\u0022\u003E3. Direct Login\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ELoginAsync\u003C/code\u003E performs a direct login submission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LoginRequest\n{\n Identifier = \u0026quot;user@example.com\u0026quot;,\n Secret = \u0026quot;password\u0026quot;\n};\n\nawait UAuthClient.Flows.LoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003ETask\u003C/code\u003E, not a structured login result.\u003C/p\u003E\n\u003Cp\u003EIt submits the login payload through the configured navigation-based transport.\u003C/p\u003E\n\u003Cp\u003EDepending on the hosting environment, this may initiate navigation or redirect behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022loginrequest-properties\u0022\u003ELoginRequest Properties\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ELoginRequest\u003C/code\u003E supports the following properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type; defaults to \u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional request metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequestTokens\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHint to request tokens when supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviewReceipt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional receipt associated with a prior login preview\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EExample:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LoginRequest\n{\n Identifier = email,\n Secret = password,\n CredentialType = CredentialType.Password,\n RequestTokens = true\n};\n\nawait UAuthClient.Flows.LoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERequestTokens\u003C/code\u003E is a hint, not a guarantee that tokens will be issued. The server\u0027s authentication mode and policy remain authoritative.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-after-login\u0022\u003ERedirect After Login\u003C/h3\u003E\n\u003Cp\u003EProvide an explicit return URL when necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LoginAsync(request, returnUrl: \u0026quot;/dashboard\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe direct login flow resolves its return URL in the following order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe explicit \u003Ccode\u003EreturnUrl\u003C/code\u003E argument.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ELogin.ReturnUrl\u003C/code\u003E option.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDefaultReturnUrl\u003C/code\u003E option.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor example, client options can specify a default:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.ReturnUrl = \u0026quot;/dashboard\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe return URL is part of the login workflow. It should not be treated as an authorization grant.\u003C/p\u003E\n\u003Cp\u003EApplications and servers must still enforce their redirect validation rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-login-with-tryloginasync\u0022\u003E4. Interactive Login with TryLoginAsync\u003C/h2\u003E\n\u003Cp\u003EFor interactive forms, \u003Ccode\u003ETryLoginAsync\u003C/code\u003E provides structured feedback before or during the authentication commit process.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryAndCommit);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is generally the more useful API for a login form that needs to display validation feedback.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submit-modes\u0022\u003ESubmit Modes\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthSubmitMode\u003C/code\u003E defines three submission behaviors.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExecute the try operation and return its result without committing authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmit directly to the commit endpoint without a separate try operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETry first, then commit when the try operation succeeds\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese modes control client workflow, not whether the server validates authentication.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDirectCommit does not bypass credential verification or server security requirements.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tryonly-validate-without-login\u0022\u003ETryOnly: Validate Without Login\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryOnly\u003C/code\u003E when you want structured feedback without establishing an authenticated session.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryOnly);\n\nif (result.IsSuccess)\n{\n // The try operation succeeded.\n // Authentication has not been committed.\n}\nelse\n{\n // Display the appropriate feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis can be useful for a custom interactive workflow.\u003C/p\u003E\n\u003Cp\u003EA successful try result must not be interpreted as an authenticated application session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tryandcommit-interactive-login\u0022\u003ETryAndCommit: Interactive Login\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryAndCommit\u003C/code\u003E to combine structured validation with authentication commit.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.TryAndCommit,\n returnUrl: \u0026quot;/dashboard\u0026quot;);\n\nif (!result.IsSuccess)\n{\n // Show validation feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen the try stage succeeds, the client proceeds to the commit operation through its configured transport.\u003C/p\u003E\n\u003Cp\u003EThis can involve navigation, so application code should not assume that execution always continues within the same component instance.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022directcommit-single-commit-submission\u0022\u003EDirectCommit: Single Commit Submission\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.TryLoginAsync(\n new LoginRequest\n {\n Identifier = email,\n Secret = password\n },\n UAuthSubmitMode.DirectCommit);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIn this mode, the SDK submits the commit operation directly.\u003C/p\u003E\n\u003Cp\u003EThe method returns a \u003Ccode\u003ETryLoginResult\u003C/code\u003E with \u003Ccode\u003EIsSuccess = true\u003C/code\u003E after the navigation submission completes, but this is not a structured server-side authentication success confirmation.\u003C/p\u003E\n\u003Cp\u003EIf you need structured credential validation feedback, prefer \u003Ccode\u003ETryAndCommit\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-login-results\u0022\u003E5. Handling Login Results\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ETryLoginAsync\u003C/code\u003E returns \u003Ccode\u003ETryLoginResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts available properties include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the try operation succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional authentication failure reason\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemainingAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERemaining attempts, when provided\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutUntilUtc\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELockout expiration, when provided\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresMfa\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether additional authentication is required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviewReceipt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional receipt for subsequent flow handling\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EExample:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryOnly);\n\nif (!result.IsSuccess)\n{\n if (result.LockoutUntilUtc is not null)\n {\n errorMessage = $\u0026quot;Login temporarily unavailable until {result.LockoutUntilUtc:u}\u0026quot;;\n }\n else\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n\n return;\n}\n\nif (result.RequiresMfa)\n{\n // Continue with the supported additional authentication flow.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not expose sensitive internal authentication details unnecessarily.\u003C/p\u003E\n\u003Cp\u003EFor public-facing login forms, generic failure messages may be preferable to displaying every failure reason.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-transport-and-protocol-errors\u0022\u003EHandling Transport and Protocol Errors\u003C/h3\u003E\n\u003Cp\u003EA structured authentication failure is different from a network or protocol failure.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Etry\n{\n var result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryAndCommit);\n\n if (!result.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n}\ncatch (UAuthTransportException)\n{\n errorMessage = \u0026quot;Authentication service is unavailable.\u0026quot;;\n}\ncatch (UAuthProtocolException)\n{\n errorMessage = \u0026quot;An authentication protocol error occurred.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the corresponding UltimateAuth client error namespace when referencing these exception types.\u003C/p\u003E\n\u003Cp\u003EA failed authentication attempt should normally be handled through the structured try result where supported. Transport and protocol exceptions indicate different failure categories.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-authentication\u0022\u003E6. PKCE Authentication\u003C/h2\u003E\n\u003Cp\u003EPKCE provides a proof mechanism for authorization-code-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports PKCE using an S256 code challenge.\u003C/p\u003E\n\u003Cp\u003EThe SDK generates a cryptographically random verifier, derives its challenge, and coordinates the authorization flow.\u003C/p\u003E\n\u003Cp\u003EPublic clients such as Blazor WebAssembly should use PKCE rather than direct credential posting.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022start-pkce\u0022\u003EStart PKCE\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.BeginPkceAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can provide a return URL:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.BeginPkceAsync(returnUrl: \u0026quot;/dashboard\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client performs the following operations:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EGenerates a PKCE code verifier.\u003C/li\u003E\n\u003Cli\u003EDerives an S256 code challenge.\u003C/li\u003E\n\u003Cli\u003ESends the authorization request.\u003C/li\u003E\n\u003Cli\u003EReceives an authorization code.\u003C/li\u003E\n\u003Cli\u003EInvokes the configured authorization callback, if any.\u003C/li\u003E\n\u003Cli\u003ENavigates to the configured UAuthHub login flow when automatic redirection is enabled.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe authorization code and verifier are used later to complete the flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-pkce\u0022\u003EConfigure PKCE\u003C/h3\u003E\n\u003Cp\u003EThe relevant client options include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.Enabled = true;\noptions.Pkce.AutoRedirect = true;\noptions.Pkce.ReturnUrl = \u0026quot;/dashboard\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is enabled by default in \u003Ccode\u003EUAuthClientPkceLoginFlowOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAutomatic redirection is also enabled by default.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022observe-authorization\u0022\u003EObserve Authorization\u003C/h3\u003E\n\u003Cp\u003EYou can register a callback that runs after the authorization code is received and before the automatic Hub redirect.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.OnAuthorized = async response =\u0026gt;\n{\n // Observe the authorization response.\n await Task.CompletedTask;\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis callback is useful for application-specific coordination.\u003C/p\u003E\n\u003Cp\u003EDo not log authorization codes or expose them to unrelated components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirection\u0022\u003EDisable Automatic Redirection\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Pkce.AutoRedirect = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith this configuration, \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E still performs the authorization request and invokes \u003Ccode\u003EOnAuthorized\u003C/code\u003E, but it does not automatically navigate to the Hub login endpoint.\u003C/p\u003E\n\u003Cp\u003EThe application becomes responsible for coordinating the remaining flow.\u003C/p\u003E\n\u003Cp\u003EThis is an advanced integration option.\u003C/p\u003E\n\u003Cp\u003EA custom implementation must preserve the authorization code, verifier, and relevant context securely and complete the flow using the supported protocol.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-pkce-login\u0022\u003E7. Complete PKCE Login\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E submits the PKCE completion request.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new PkceCompleteRequest\n{\n AuthorizationCode = authorizationCode,\n CodeVerifier = codeVerifier,\n Identifier = email,\n Secret = password,\n ReturnUrl = \u0026quot;/dashboard\u0026quot;\n};\n\nawait UAuthClient.Flows.CompletePkceLoginAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECode obtained from the authorization step\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeVerifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOriginal PKCE verifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional post-login destination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional Hub session context\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization code and verifier must correspond to the same PKCE flow.\u003C/p\u003E\n\u003Cp\u003EDo not generate a new verifier when completing an existing authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-pkce-completion\u0022\u003EInteractive PKCE Completion\u003C/h3\u003E\n\u003Cp\u003EFor structured feedback, use \u003Ccode\u003ETryCompletePkceLoginAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryCompletePkceLoginAsync(\n request,\n UAuthSubmitMode.TryAndCommit);\n\nif (!result.IsSuccess)\n{\n // Display PKCE login feedback.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same submit modes are supported:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022try-pkce-without-commit\u0022\u003ETry PKCE Without Commit\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryOnly);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis returns a \u003Ccode\u003ETryPkceLoginResult\u003C/code\u003E without committing a successful login.\u003C/p\u003E\n\u003Cp\u003EThe result includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eresult.IsSuccess;\nresult.Reason;\nresult.RemainingAttempts;\nresult.LockoutUntilUtc;\nresult.RequiresMfa;\nresult.RetryWithNewPkce;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrywithnewpkce\u0022\u003ERetryWithNewPkce\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERetryWithNewPkce\u003C/code\u003E indicates that the client should begin a new PKCE authorization sequence rather than continuing with the existing authorization material.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.RetryWithNewPkce)\n{\n await UAuthClient.Flows.BeginPkceAsync(\u0026quot;/login\u0026quot;);\n return;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when the current PKCE context can no longer be used.\u003C/p\u003E\n\u003Cp\u003EThe application should not repeatedly resubmit the same invalid authorization code and verifier.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022direct-credential-posting-restrictions\u0022\u003E8. Direct Credential Posting Restrictions\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth restricts direct credential posting according to the configured client profile.\u003C/p\u003E\n\u003Cp\u003EThe client option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.AllowCredentialPost = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is the default.\u003C/p\u003E\n\u003Cp\u003EIf the current profile is not permitted to post credentials directly and the option has not enabled that capability, direct login methods throw an \u003Ccode\u003EInvalidOperationException\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, attempting direct login from a public client may produce an error indicating that PKCE is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022trusted-server-hosted-clients\u0022\u003ETrusted Server-Hosted Clients\u003C/h3\u003E\n\u003Cp\u003EThe option can be explicitly configured for a trusted server-hosted integration where direct credential posting is appropriate:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Login.AllowCredentialPost = true;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis must not be used to bypass PKCE requirements in a public browser application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable direct credential posting in production Blazor WebAssembly or SPA clients.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EA client profile is an integration descriptor, not cryptographic proof that the caller is trusted.\u003C/p\u003E\n\u003Cp\u003EServer-side security requirements remain authoritative.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-authentication\u0022\u003E9. Refresh Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E requests continuation of the current authentication state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003ERefreshResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RefreshResult\n{\n public bool IsSuccess { get; init; }\n public int Status { get; init; }\n public RefreshOutcome Outcome { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERefresh behavior depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EA refresh operation may update session activity, rotate authentication material, or determine that reauthentication is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-refresh-results\u0022\u003EHandling Refresh Results\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\nif (result.IsSuccess)\n{\n // Authentication continuation succeeded.\n}\nelse if (result.Outcome == RefreshOutcome.ReauthRequired)\n{\n // The current authentication can no longer continue.\n // Direct the user to the appropriate login flow.\n}\nelse\n{\n // Handle another unsuccessful refresh result.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA \u003Ccode\u003E401 Unauthorized\u003C/code\u003E refresh response is handled as a normal reauthentication outcome.\u003C/p\u003E\n\u003Cp\u003EIt returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Enew RefreshResult\n{\n IsSuccess = false,\n Status = 401,\n Outcome = RefreshOutcome.ReauthRequired\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENetwork and unexpected protocol failures can still produce exceptions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-outcomes\u0022\u003ERefresh Outcomes\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshOutcome\u003C/code\u003E defines the following values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOutcome\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo specific semantic outcome\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EGeneric successful refresh\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENoOp\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo refresh state change was necessary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouched\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EApplicable session activity was updated\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERotation occurred\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReauthRequired\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication continuation is no longer permitted\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.RefreshAsync();\n\nif (!result.IsSuccess)\n{\n return;\n}\n\nswitch (result.Outcome)\n{\n case RefreshOutcome.Rotated:\n // Authentication material was rotated.\n break;\n\n case RefreshOutcome.Touched:\n // Applicable session activity was updated.\n break;\n\n case RefreshOutcome.NoOp:\n // Successful refresh and no state change was required.\n break;\n\n case RefreshOutcome.Success:\n // Refresh succeeded without a more specific outcome.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detailed-refresh-diagnostics\u0022\u003EDetailed Refresh Diagnostics\u003C/h3\u003E\n\u003Cp\u003EThe server may return a generic success outcome rather than exposing the internal refresh action.\u003C/p\u003E\n\u003Cp\u003EWhere supported, detailed refresh outcomes can be enabled through server diagnostics:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is intended for diagnostics and observability.\u003C/p\u003E\n\u003Cp\u003EApplications should not require a specific successful outcome such as \u003Ccode\u003ERotated\u003C/code\u003E or \u003Ccode\u003ETouched\u003C/code\u003E to consider refresh successful.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsSuccess\u003C/code\u003E for the primary success decision.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022manual-and-automatic-refresh\u0022\u003EManual and Automatic Refresh\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshAsync\u003C/code\u003E has an optional \u003Ccode\u003EisAuto\u003C/code\u003E argument.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E// Manual refresh\nawait UAuthClient.Flows.RefreshAsync();\n\n// Refresh initiated by automatic coordination\nawait UAuthClient.Flows.RefreshAsync(isAuto: true);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe parameter distinguishes automatic refresh activity for client diagnostics.\u003C/p\u003E\n\u003Cp\u003EIt does not select a different authentication mode or override the server\u0027s refresh policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022automatic-refresh-configuration\u0022\u003E10. Automatic Refresh Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes client-side automatic refresh options through \u003Ccode\u003EAutoRefresh\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AutoRefresh.Enabled = true;\noptions.AutoRefresh.Interval = TimeSpan.FromMinutes(5);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEnabled\u003C/code\u003E controls background refresh coordination.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EInterval\u003C/code\u003E specifies the interval used for background refresh attempts.\u003C/p\u003E\n\u003Cp\u003EThe effective defaults and registration behavior depend on the client integration. The options documentation identifies Blazor Server as the environment where background refresh is enabled by default.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022automatic-refresh-is-not-a-security-guarantee\u0022\u003EAutomatic Refresh Is Not a Security Guarantee\u003C/h3\u003E\n\u003Cp\u003EAutomatic refresh is a client-side convenience mechanism.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that a session will remain valid indefinitely.\u003C/p\u003E\n\u003Cp\u003EAuthentication may still end because of:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EExpiration.\u003C/li\u003E\n\u003Cli\u003ERevocation.\u003C/li\u003E\n\u003Cli\u003EAccount security changes.\u003C/li\u003E\n\u003Cli\u003EInvalid refresh credentials.\u003C/li\u003E\n\u003Cli\u003EServer-side continuation restrictions.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe client must remain prepared to handle \u003Ccode\u003EReauthRequired\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validate-current-authentication\u0022\u003E11. Validate Current Authentication\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EValidateAsync\u003C/code\u003E to ask the server to validate the current authentication state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.ValidateAsync();\n\nif (result.IsValid)\n{\n // Current authentication is valid.\n}\nelse\n{\n // Authentication is not currently valid.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EAuthValidationResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts properties include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsValid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the state is \u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESnapshot\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional authentication state snapshot\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional associated session chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERootId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional session root\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBoundDeviceId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional bound device identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-context\u0022\u003EInspect Session Context\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.ValidateAsync();\n\nif (result.IsValid \u0026amp;\u0026amp; result.ChainId is { } chainId)\n{\n // Use chainId for supported session-management operations.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENot every authentication mode necessarily provides all session hierarchy fields.\u003C/p\u003E\n\u003Cp\u003ETreat optional values as optional.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validateasync-versus-ui-authentication-state\u0022\u003EValidateAsync Versus UI Authentication State\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E performs an explicit server validation operation.\u003C/p\u003E\n\u003Cp\u003EIt should not be called on every UI render merely to determine whether a component should display authenticated content.\u003C/p\u003E\n\u003Cp\u003EFor UI authentication state, use the supported UltimateAuth state management integration, including \u003Ccode\u003EIUAuthStateManager\u003C/code\u003E where appropriate.\u003C/p\u003E\n\u003Cp\u003EExplicit validation is useful when an application needs to check current server-recognized authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003E12. Logout\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ELogoutAsync\u003C/code\u003E performs the current logout flow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method uses the configured logout endpoint and navigation transport.\u003C/p\u003E\n\u003Cp\u003EDepending on the client profile and configuration, logout may involve navigation and authentication state changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-button\u0022\u003ELogout Button\u003C/h3\u003E\n\u003Cp\u003EA simple Blazor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\n\u0026lt;button @onclick=\u0026quot;LogoutAsync\u0026quot;\u0026gt;Logout\u0026lt;/button\u0026gt;\n\n@code {\n private async Task LogoutAsync()\n {\n await UAuthClient.Flows.LogoutAsync();\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELogout is distinct from clearing local UI state.\u003C/p\u003E\n\u003Cp\u003ERemoving a local authentication indicator does not necessarily revoke authoritative server-managed authentication state.\u003C/p\u003E\n\u003Cp\u003EUse the supported logout flow to terminate authentication.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-specific-device\u0022\u003E13. Logout a Specific Device\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports revoking the session chain associated with a particular device context.\u003C/p\u003E\n\u003Cp\u003EThe relevant method is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ELogoutMyDeviceAsync(LogoutDeviceRequest request)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E requires a \u003Ccode\u003ESessionChainId\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutDeviceRequest\n{\n ChainId = chainId\n};\n\nvar result = await UAuthClient.Flows.LogoutMyDeviceAsync(request);\n\nif (result.IsSuccess)\n{\n // The requested device logout operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation targets a session chain, not an arbitrary individual session ID.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022current-device-behavior\u0022\u003ECurrent Device Behavior\u003C/h3\u003E\n\u003Cp\u003EThe returned \u003Ccode\u003ERevokeResult\u003C/code\u003E can indicate whether the affected chain is the current chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the client publishes a logout-related state event.\u003C/p\u003E\n\u003Cp\u003EThis allows the application to react when the currently authenticated device context has been revoked.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-other-devices\u0022\u003E14. Logout Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ELogoutMyOtherDevicesAsync\u003C/code\u003E to revoke other device-associated sessions while retaining the current one.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync();\n\nif (result.IsSuccess)\n{\n // Other devices were logged out.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for account security pages that provide a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action.\u003C/p\u003E\n\u003Cp\u003EThe current authentication context is intended to remain active.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for identifying the current session or chain and enforcing the appropriate revocation scope.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-all-devices\u0022\u003E15. Logout All Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ELogoutAllMyDevicesAsync\u003C/code\u003E to revoke all applicable sessions for the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutAllMyDevicesAsync();\n\nif (result.IsSuccess)\n{\n // All applicable device sessions were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOn success, the client publishes a logout-related state event.\u003C/p\u003E\n\u003Cp\u003EThis operation is useful when a user suspects account compromise or wants to terminate existing authentication sessions across devices.\u003C/p\u003E\n\u003Cp\u003EIn JWT-based modes, previously issued access tokens may remain cryptographically valid until expiration unless additional revocation enforcement is configured.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-device-logout\u0022\u003E16. Administrative Device Logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth also exposes administrative session revocation operations.\u003C/p\u003E\n\u003Cp\u003EThese methods act on a specified user and require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-one-user-device\u0022\u003ELogout One User Device\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutUserDeviceAsync(\n userKey,\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\nif (!result.IsSuccess)\n{\n // Handle the failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-other-devices-for-a-user\u0022\u003ELogout Other Devices for a User\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutUserOtherDevicesAsync(userKey, request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHere, \u003Ccode\u003Erequest\u003C/code\u003E is a \u003Ccode\u003ELogoutOtherDevicesRequest\u003C/code\u003E identifying the chain to retain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-all-devices-for-a-user\u0022\u003ELogout All Devices for a User\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(userKey);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative methods do not grant administrative privileges by themselves.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize the actor and validate the target user and requested revocation scope.\u003C/p\u003E\n\u003Cp\u003EThese operations are particularly relevant to administration panels and account security workflows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022building-login-forms-with-uauthloginform\u0022\u003E17. Building Login Forms with UAuthLoginForm\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E, a reusable Blazor component that handles authentication form submission without requiring developers to implement the underlying flow orchestration themselves.\u003C/p\u003E\n\u003Cp\u003EThe component supports password-based login, PKCE completion through UAuthHub, configurable submission modes, structured validation feedback, and customizable UI content.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EYou design the login interface. UltimateAuth handles the authentication submission workflow.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic-usage\u0022\u003EBasic Usage\u003C/h3\u003E\n\u003Cp\u003EA login form can be implemented with the following component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;Password\u0026quot; /\u0026gt;\n \u0026lt;button type=\u0026quot;submit\u0026quot;\u0026gt;Sign in\u0026lt;/button\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe component automatically uses \u003Ccode\u003ETryAndCommit\u003C/code\u003E as its default submission mode.\u003C/p\u003E\n\u003Cp\u003EWhen submitted, it:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EValidates that the identifier and secret are present.\u003C/li\u003E\n\u003Cli\u003ECreates the appropriate authentication request.\u003C/li\u003E\n\u003Cli\u003ECalls the UltimateAuth client flow.\u003C/li\u003E\n\u003Cli\u003EPerforms the configured try/commit behavior.\u003C/li\u003E\n\u003Cli\u003EExposes structured results through \u003Ccode\u003EOnTryResult\u003C/code\u003E.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe example assumes that UltimateAuth\u0027s Blazor services have already been registered and that the hosting environment permits the selected login flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customizing-the-login-ui\u0022\u003ECustomizing the Login UI\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthLoginForm\u003C/code\u003E uses \u003Ccode\u003EChildContent\u003C/code\u003E, allowing developers to use standard HTML elements or their preferred Blazor component library.\u003C/p\u003E\n\u003Cp\u003EFor example, with MudBlazor:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n@using CodeBeam.UltimateAuth.Client\n\n\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot; OnTryResult=\u0026quot;HandleLoginResult\u0026quot;\u0026gt;\n \u0026lt;MudStack Spacing=\u0026quot;3\u0026quot;\u0026gt;\n \u0026lt;MudText Typo=\u0026quot;Typo.h5\u0026quot;\u0026gt;Sign in\u0026lt;/MudText\u0026gt;\n \u0026lt;MudTextField @bind-Value=\u0026quot;email\u0026quot; Label=\u0026quot;Email\u0026quot; Variant=\u0026quot;Variant.Outlined\u0026quot; /\u0026gt;\n \u0026lt;MudTextField @bind-Value=\u0026quot;password\u0026quot; Label=\u0026quot;Password\u0026quot; InputType=\u0026quot;InputType.Password\u0026quot; Variant=\u0026quot;Variant.Outlined\u0026quot; /\u0026gt;\n\n \u0026lt;MudButton ButtonType=\u0026quot;ButtonType.Submit\u0026quot; Variant=\u0026quot;Variant.Filled\u0026quot; Color=\u0026quot;Color.Primary\u0026quot; FullWidth\u0026gt;Sign in\u0026lt;/MudButton\u0026gt;\n\n @if (!string.IsNullOrWhiteSpace(error))\n {\n \u0026lt;MudAlert Severity=\u0026quot;Severity.Error\u0026quot;\u0026gt;@error\u0026lt;/MudAlert\u0026gt;\n }\n \u0026lt;/MudStack\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n private string? error;\n\n private Task HandleLoginResult(IUAuthTryResult result)\n {\n error = result.IsSuccess ? null : \u0026quot;Unable to sign in.\u0026quot;;\n return Task.CompletedTask;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe form controls remain under your control.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not require a particular visual design, CSS framework, or component library.\u003C/p\u003E\n\u003Cp\u003EBecause the component renders an HTML form internally, place the submit button inside its child content and avoid nesting another HTML form within it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submission-modes\u0022\u003ESubmission Modes\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESubmitMode\u003C/code\u003E to control how the authentication attempt is executed.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryOnly\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidates the authentication attempt without committing login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAttempts authentication and commits on success\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmits directly to the commit flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E is the default.\u003C/p\u003E\n\u003Cp\u003EFor interactive login forms that need validation feedback, it is generally the recommended choice.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EDirectCommit\u003C/code\u003E does not invoke \u003Ccode\u003EOnTryResult\u003C/code\u003E, because it does not perform the structured try operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-authentication-results\u0022\u003EHandling Authentication Results\u003C/h3\u003E\n\u003Cp\u003EThe component exposes an \u003Ccode\u003EOnTryResult\u003C/code\u003E callback:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; OnTryResult=\u0026quot;HandleResult\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe callback receives \u003Ccode\u003EIUAuthTryResult\u003C/code\u003E, allowing the same component to report password login and PKCE completion results.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eprivate Task HandleResult(IUAuthTryResult result)\n{\n if (!result.IsSuccess)\n {\n errorMessage = \u0026quot;Authentication failed.\u0026quot;;\n }\n\n return Task.CompletedTask;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor flow-specific details, inspect the concrete result type:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eprivate Task HandleResult(IUAuthTryResult result)\n{\n if (result is TryLoginResult loginResult)\n {\n if (loginResult.LockoutUntilUtc is not null)\n {\n errorMessage = \u0026quot;Your account is temporarily locked.\u0026quot;;\n }\n else if (loginResult.RequiresMfa)\n {\n // Continue the supported additional authentication workflow.\n }\n else if (!loginResult.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to sign in.\u0026quot;;\n }\n }\n else if (result is TryPkceLoginResult pkceResult)\n {\n if (pkceResult.RetryWithNewPkce)\n {\n errorMessage = \u0026quot;Please restart the authentication flow.\u0026quot;;\n }\n else if (!pkceResult.IsSuccess)\n {\n errorMessage = \u0026quot;Unable to complete authentication.\u0026quot;;\n }\n }\n\n return Task.CompletedTask;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe callback reports the structured try result. It does not replace authentication state management or server-side validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-uauthloginform-with-pkce\u0022\u003EUsing UAuthLoginForm with PKCE\u003C/h3\u003E\n\u003Cp\u003EThe same component also supports PKCE login completion when hosted within a supported UAuthHub environment.\u003C/p\u003E\n\u003Cp\u003ESet \u003Ccode\u003ELoginType\u003C/code\u003E to \u003Ccode\u003EPkce\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm LoginType=\u0026quot;UAuthLoginType.Pkce\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.TryAndCommit\u0026quot; OnTryResult=\u0026quot;HandleLoginResult\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;Password\u0026quot; /\u0026gt;\n \u0026lt;button type=\u0026quot;submit\u0026quot;\u0026gt;Continue\u0026lt;/button\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIn PKCE mode, the component:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EResolves the relevant UAuthHub session context.\u003C/li\u003E\n\u003Cli\u003ERetrieves the authorization code and code verifier through the configured Hub services.\u003C/li\u003E\n\u003Cli\u003EBuilds a \u003Ccode\u003EPkceCompleteRequest\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EInvokes the corresponding PKCE completion flow.\u003C/li\u003E\n\u003Cli\u003EReports the structured result when a try-enabled submission mode is used.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe application does not need to manually construct the PKCE completion request for this form.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E PKCE mode requires a supported server-side UAuthHub environment. \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E checks the available Hub capabilities and throws when PKCE login is requested in an unsupported client profile.\u003C/p\u003E\n\u003Cp\u003EA Blazor WebAssembly application should initiate PKCE using \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E; the Hub-hosted login form handles the completion stage.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022return-url-handling\u0022\u003EReturn URL Handling\u003C/h3\u003E\n\u003Cp\u003EYou can provide an explicit return URL:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; ReturnUrl=\u0026quot;/dashboard\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe component resolves the effective return URL using the following precedence:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003EReturnUrl\u003C/code\u003E parameter.\u003C/li\u003E\n\u003Cli\u003EUAuthHub flow return URL when using PKCE.\u003C/li\u003E\n\u003Cli\u003ECurrent navigation URL.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThis allows a shared login component to participate in different authentication journeys without hardcoding navigation logic into the form.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-endpoints\u0022\u003ECustom Login Endpoints\u003C/h3\u003E\n\u003Cp\u003EFor advanced integrations, the \u003Ccode\u003EEndpoint\u003C/code\u003E parameter allows the form\u0027s HTML action endpoint to be overridden:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; Endpoint=\u0026quot;/custom-login\u0026quot; SubmitMode=\u0026quot;UAuthSubmitMode.DirectCommit\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe endpoint is resolved relative to the configured UltimateAuth endpoint base path and multi-tenant settings.\u003C/p\u003E\n\u003Cp\u003ENote that \u003Ccode\u003EEndpoint\u003C/code\u003E affects the form\u0027s HTML action. Try-based submissions invoke the configured \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E endpoints, so changing \u003Ccode\u003EEndpoint\u003C/code\u003E alone does not redirect those SDK requests to a custom endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022submit-the-form-programmatically\u0022\u003ESubmit the Form Programmatically\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthLoginForm\u003C/code\u003E exposes \u003Ccode\u003ESubmitAsync\u003C/code\u003E, allowing submission through a component reference.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm @ref=\u0026quot;loginForm\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; AllowEnterKeyToSubmit=\u0026quot;false\u0026quot;\u0026gt;\n \u0026lt;input @bind=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email\u0026quot; /\u0026gt;\n \u0026lt;input @bind=\u0026quot;password\u0026quot; type=\u0026quot;password\u0026quot; /\u0026gt;\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;SubmitLoginAsync\u0026quot;\u0026gt;\n Sign in\n\u0026lt;/button\u0026gt;\n\n@code {\n private UAuthLoginForm? loginForm;\n\n private string email = \u0026quot;\u0026quot;;\n private string password = \u0026quot;\u0026quot;;\n\n private async Task SubmitLoginAsync()\n {\n if (loginForm is not null)\n {\n await loginForm.SubmitAsync();\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ESubmitAsync\u003C/code\u003E must be called after the component has rendered.\u003C/p\u003E\n\u003Cp\u003EThis is useful when submission is initiated from a custom button, dialog, or application-specific interaction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022controlling-enter-key-submission\u0022\u003EControlling Enter-Key Submission\u003C/h3\u003E\n\u003Cp\u003EBy default, the component supports Enter-key submission.\u003C/p\u003E\n\u003Cp\u003EYou can disable its built-in hidden submit button:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot; AllowEnterKeyToSubmit=\u0026quot;false\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls the component\u0027s built-in Enter-key submit mechanism. Other submit buttons inside the form may still trigger normal browser form submission behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reloading-pkce-context\u0022\u003EReloading PKCE Context\u003C/h3\u003E\n\u003Cp\u003EThe component exposes \u003Ccode\u003EReloadAsync\u003C/code\u003E to reload its Hub credentials and flow state.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E\u0026lt;UAuthLoginForm @ref=\u0026quot;loginForm\u0026quot; LoginType=\u0026quot;UAuthLoginType.Pkce\u0026quot; Identifier=\u0026quot;@email\u0026quot; Secret=\u0026quot;@password\u0026quot;\u0026gt;\n ...\n\u0026lt;/UAuthLoginForm\u0026gt;\n\n@code {\n private UAuthLoginForm? loginForm;\n\n private async Task ReloadLoginContextAsync()\n {\n if (loginForm is not null)\n {\n await loginForm.ReloadAsync();\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an advanced capability intended for scenarios where the associated Hub state changes and the component must refresh its internal context.\u003C/p\u003E\n\u003Cp\u003EIt does not initiate a new PKCE authorization flow by itself.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022component-parameters\u0022\u003EComponent Parameters\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EParameter\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassword or PKCE login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESubmitMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSubmitMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryAndCommit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESubmission strategy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERedirect destination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustom HTML form endpoint\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExplicit Hub session context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowEnterKeyToSubmit\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBuilt-in Enter-key submit support\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnTryResult\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EEventCallback\u0026lt;IUAuthTryResult\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u2014\u003C/td\u003E\n\u003Ctd\u003EStructured try result callback\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChildContent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERenderFragment?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u2014\u003C/td\u003E\n\u003Ctd\u003ECustom form content\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022when-to-use-uauthloginform\u0022\u003EWhen to Use UAuthLoginForm\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUAuthLoginForm\u003C/code\u003E when building a Blazor login interface that should integrate directly with UltimateAuth\u0027s supported authentication flows.\u003C/p\u003E\n\u003Cp\u003EIt is particularly useful when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou want to use your own component library without implementing the authentication submission logic.\u003C/li\u003E\n\u003Cli\u003EYou need structured login validation feedback.\u003C/li\u003E\n\u003Cli\u003EYou want to switch between try-only and commit behavior.\u003C/li\u003E\n\u003Cli\u003EYou are building a UAuthHub login page that completes PKCE.\u003C/li\u003E\n\u003Cli\u003EYou want to share login UI across supported Blazor hosting environments.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor application-specific workflows that cannot be expressed through the component, use \u003Ccode\u003EUAuthClient.Flows\u003C/code\u003E directly.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe component handles the login workflow, while your application retains control over presentation, user experience, and surrounding application logic.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-integration-mistakes\u0022\u003E18. Common Integration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-direct-login-from-a-public-client\u0022\u003ECalling Direct Login from a Public Client\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E \u003Ccode\u003ELoginAsync\u003C/code\u003E throws because direct credential posting is disabled.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Use \u003Ccode\u003EBeginPkceAsync\u003C/code\u003E and the supported PKCE completion flow. Do not enable \u003Ccode\u003EAllowCredentialPost\u003C/code\u003E to bypass the public-client restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-tryonly-as-authentication\u0022\u003ETreating TryOnly as Authentication\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E The application receives \u003Ccode\u003EIsSuccess = true\u003C/code\u003E from \u003Ccode\u003ETryOnly\u003C/code\u003E and assumes the user is logged in.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E A successful try result does not commit authentication. Use \u003Ccode\u003ETryAndCommit\u003C/code\u003E or the appropriate commit flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-directcommit-skips-server-validation\u0022\u003EAssuming DirectCommit Skips Server Validation\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E DirectCommit is interpreted as a faster but less secure authentication mode.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E DirectCommit skips the separate client try stage. The server still validates the actual authentication request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-every-refresh-rotates-tokens\u0022\u003EAssuming Every Refresh Rotates Tokens\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E The application requires \u003Ccode\u003ERefreshOutcome.Rotated\u003C/code\u003E for refresh success.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Check \u003Ccode\u003ERefreshResult.IsSuccess\u003C/code\u003E. Rotation, touch, and no-op behavior depend on the authentication mode and refresh strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-a-session-id-for-device-logout\u0022\u003EUsing a Session ID for Device Logout\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E A session identifier is passed to a device logout operation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E \u003Ccode\u003ELogoutMyDeviceAsync\u003C/code\u003E requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E with a \u003Ccode\u003ESessionChainId\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-logout-immediately-invalidates-all-jwts\u0022\u003EAssuming Logout Immediately Invalidates All JWTs\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E An application expects every issued JWT access token to become unusable immediately after server-side logout.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Understand the enforcement behavior of SemiHybrid and PureJwt. Local JWT validation may continue accepting previously issued tokens until expiration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-validateasync-on-every-render\u0022\u003EUsing ValidateAsync on Every Render\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EProblem:\u003C/strong\u003E Components repeatedly make validation requests just to display authenticated UI.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESolution:\u003C/strong\u003E Use the appropriate authentication state integration for UI rendering, and reserve explicit validation for operations that require it.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and manage session and device information.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validity, refresh behavior, and revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/refresh-rotation\u0022\u003ERefresh Rotation \u0026amp; Replay Protection\u003C/a\u003E \u2014 Explore refresh concurrency and reuse security.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E \u2014 Compare authentication behavior across modes.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Understand device-associated session chains.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "accessing-the-authentication-client", @@ -329,6 +329,6 @@ "Level": 0 } ], - "SourceHash": "E08764ABC9FE77E47DEDD39DFB7BC9C174BD3482E71CF20D8547489902D0DC6D", + "SourceHash": "2A94010322C7A85EC49E84E84F1E73737F414AE2A5FCCED1BAED1E3C3FD11729", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authorization.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authorization.json index 427e1a0d..7d7fb69e 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authorization.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authorization.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/authorization", "Title": "Authorization", - "Html": "\n\u003Cp\u003EUltimateAuth provides a unified client API for managing roles, assigning permissions, controlling user-role relationships, and requesting authorization decisions from the server.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Authorization\u003C/code\u003E, applications can build administrative authorization interfaces and check whether the current user is allowed to perform a particular action.\u003C/p\u003E\n\u003Cp\u003EAuthorization is enforced on the server. Client-side permission checks are useful for improving the user experience, but they do not replace authorization at the endpoint or application-service level.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EIAuthorizationClient\u003C/code\u003E API, practical role management workflows, permission patterns, and secure client integration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-authorization\u0022\u003E1. Understanding Authorization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth authorization combines several concepts:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERoles\u003C/td\u003E\n\u003Ctd\u003ENamed collections of permissions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPermissions\u003C/td\u003E\n\u003Ctd\u003EFine-grained capabilities represented by normalized values\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser-role assignments\u003C/td\u003E\n\u003Ctd\u003EAssociations between users and roles\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPolicies\u003C/td\u003E\n\u003Ctd\u003EServer-side rules that participate in access decisions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthorization context\u003C/td\u003E\n\u003Ctd\u003EInformation about the actor, resource, action, and current security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA typical authorization relationship looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Manager\n | |\n | \u002B-- products.read\n | \u002B-- products.create\n | \u002B-- products.update\n |\n \u002B-- Auditor\n |\n \u002B-- reports.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA user can have multiple roles, and each role can contain multiple permissions.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ehaving a permission does not automatically guarantee access\u003C/strong\u003E. The server\u0027s authorization pipeline may apply additional requirements, such as resource ownership, session validity, tenant isolation, or application policies.\u003C/p\u003E\n\u003Cp\u003EFor the internal decision model, see \u003Ca href=\u0022/docs/security/authorization-pipeline\u0022\u003EAuthorization Pipeline Internals\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-authorization-client\u0022\u003E2. Accessing the Authorization Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAuthorization operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Authorization\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property exposes \u003Ccode\u003EIAuthorizationClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface provides three main groups of operations:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EAuthorization checks:\u003C/strong\u003E Evaluate whether the current user may perform an action.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERole management:\u003C/strong\u003E Create, query, rename, configure, and delete roles.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUser-role management:\u003C/strong\u003E Retrieve, assign, and remove user roles.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe SDK sends requests to the corresponding UltimateAuth endpoints and returns structured \u003Ccode\u003EUAuthResult\u003C/code\u003E values.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-permissions\u0022\u003E3. Understanding Permissions\u003C/h2\u003E\n\u003Cp\u003EA permission is represented by the \u003Ccode\u003EPermission\u003C/code\u003E value type.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permission = Permission.From(\u0026quot;products.create\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation normalizes permission values by trimming surrounding whitespace and converting the value to lowercase.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permission = Permission.From(\u0026quot; PRODUCTS.CREATE \u0026quot;);\n\nConsole.WriteLine(permission.Value);\n// products.create\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe constructor rejects null, empty, and whitespace-only values.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022permission-patterns\u0022\u003EPermission Patterns\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth recognizes three important forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPattern\u003C/th\u003E\n\u003Cth\u003EExample\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EExact\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eproducts.create\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EA specific permission\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPrefix wildcard\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eproducts.*\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermissions within the corresponding prefix\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EGlobal wildcard\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E*\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnrestricted permission matching within the applicable authorization rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ECreate them using:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar exact = Permission.From(\u0026quot;products.create\u0026quot;);\n\nvar prefix = Permission.From(\u0026quot;products.*\u0026quot;);\n\nvar global = Permission.Wildcard;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe type also exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epermission.IsWildcard;\npermission.IsPrefix;\npermission.IsValid;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022permission-normalization\u0022\u003EPermission Normalization\u003C/h3\u003E\n\u003Cp\u003EThe following values normalize to the same representation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.From(\u0026quot;Products.Create\u0026quot;);\nPermission.From(\u0026quot;products.create\u0026quot;);\nPermission.From(\u0026quot; PRODUCTS.CREATE \u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAll produce:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.create\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse consistent permission names across application services, policies, and role configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022wildcards-are-powerful\u0022\u003EWildcards Are Powerful\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.From(\u0026quot;users.*\u0026quot;)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Emay grant broad access to operations within the \u003Ccode\u003Eusers\u003C/code\u003E permission prefix.\u003C/p\u003E\n\u003Cp\u003EThe global wildcard:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.Wildcard\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eis especially sensitive.\u003C/p\u003E\n\u003Cp\u003EAvoid assigning broad wildcard permissions merely to simplify development. Use least-privilege permissions for ordinary application roles.\u003C/p\u003E\n\u003Cp\u003EA wildcard is not a substitute for satisfying independent security policies, authentication requirements, or tenant restrictions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-authorization\u0022\u003E4. Check Authorization\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E evaluates whether the current authenticated user is authorized to perform a specific action against a resource.\u003C/p\u003E\n\u003Cp\u003EIt provides an \u003Cstrong\u003Eon-demand, server-side authorization decision\u003C/strong\u003E based on the current authentication context, permissions, roles, and applicable authorization policies.\u003C/p\u003E\n\u003Cp\u003EUnlike operations that perform protected actions, \u003Ccode\u003ECheckAsync\u003C/code\u003E only evaluates access. It does not execute the requested operation, modify permissions, grant access, or reserve authorization for a future request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022when-to-use-checkasync\u0022\u003EWhen to Use CheckAsync\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAsync\u003C/code\u003E when your application needs to:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDetermine whether an authenticated user can perform an action before presenting it in the UI.\u003C/li\u003E\n\u003Cli\u003EEnable, disable, or hide controls based on the current authorization decision.\u003C/li\u003E\n\u003Cli\u003EExplain why an operation is unavailable, using the returned denial reason where appropriate.\u003C/li\u003E\n\u003Cli\u003EDetermine whether an operation requires reauthentication.\u003C/li\u003E\n\u003Cli\u003EEvaluate resource-specific access when the applicable policies support it.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, an application may check whether the current user can delete a particular order before displaying the Delete button.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-checkasync-does-not-guarantee\u0022\u003EWhat CheckAsync Does Not Guarantee\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EA successful authorization check is not a guarantee that a subsequent operation will succeed.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EAuthorization is evaluated against the security context available at the time of the check. That context may change before the actual operation is performed.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAn administrator may revoke the user\u0027s permissions.\u003C/li\u003E\n\u003Cli\u003EThe user\u0027s session may expire or become invalid.\u003C/li\u003E\n\u003Cli\u003EThe target resource may be deleted or modified.\u003C/li\u003E\n\u003Cli\u003EThe resource\u0027s ownership or tenant context may change.\u003C/li\u003E\n\u003Cli\u003EAdditional business rules may prevent the operation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe protected operation must independently enforce authorization using its current context.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever use a previous \u003Ccode\u003ECheckAsync\u003C/code\u003E result as proof of authorization for a later request.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E is primarily a developer-experience and user-experience API. It helps applications understand and present authorization decisions without replacing the security enforcement performed by protected endpoints and application services.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAsync\u003C/code\u003E to ask the server whether the current user is authorized to perform an action.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;read\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true })\n{\n // The server authorized this action.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;AuthorizationResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorizationcheckrequest\u0022\u003EAuthorizationCheckRequest\u003C/h3\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAction\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResource\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResource being accessed\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResourceId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional identifier for a specific resource instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EBoth \u003Ccode\u003EAction\u003C/code\u003E and \u003Ccode\u003EResource\u003C/code\u003E are required.\u003C/p\u003E\n\u003Cp\u003EThe action and resource values must follow the conventions understood by your server-side authorization configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-a-specific-resource\u0022\u003ECheck a Specific Resource\u003C/h3\u003E\n\u003Cp\u003EFor example, check access to a particular product:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;update\u0026quot;,\n Resource = \u0026quot;products\u0026quot;,\n ResourceId = productId.ToString()\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EProviding \u003Ccode\u003EResourceId\u003C/code\u003E allows the server to evaluate resource-specific rules where supported by the configured authorization pipeline.\u003C/p\u003E\n\u003Cp\u003EFor example, the application may distinguish between updating any product and updating a product owned by the current user.\u003C/p\u003E\n\u003Cp\u003EDo not assume that sending \u003Ccode\u003EResourceId\u003C/code\u003E automatically enables ownership checking. The relevant server-side policy must implement the required rule.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-authorization-results\u0022\u003E5. Understanding Authorization Results\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E returns an \u003Ccode\u003EAuthorizationResult\u003C/code\u003E with the following properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the request was authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresReauthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the decision indicates reauthentication is required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDenyReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional denial reason code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-decisions-vs.http-results\u0022\u003EAuthorization Decisions vs. HTTP Results\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E separates the outcome of the API request from the authorization decision itself.\u003C/p\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;AuthorizationResult\u0026gt;\u003C/code\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthResult.IsSuccess\u003C/code\u003E indicates whether the authorization check request completed successfully.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.IsAllowed\u003C/code\u003E indicates whether the requested action is currently authorized.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.RequiresReauthentication\u003C/code\u003E indicates whether additional authentication is required.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.DenyReason\u003C/code\u003E provides an optional reason for denial.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA denied authorization decision is a valid result of a successful authorization check.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScenario\u003C/th\u003E\n\u003Cth\u003EHTTP Status\u003C/th\u003E\n\u003Cth\u003EAuthorization Result\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAction allowed\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed = true\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAction denied\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed = false\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EReauthentication required\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresReauthentication = true\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECaller not authenticated\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E401 Unauthorized\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInvalid request\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E400 Bad Request\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServer-side evaluation failure\u003C/td\u003E\n\u003Ctd\u003EAppropriate error status\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EHTTP 200 means the authorization evaluation completed successfully. It does not mean access was granted.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EOnly authenticated callers can use the current \u003Ccode\u003ECheckAsync\u003C/code\u003E endpoint. However, callers do not need permission to perform the action they are checking.\u003C/p\u003E\n\u003Cp\u003EThis distinction is intentional: the purpose of \u003Ccode\u003ECheckAsync\u003C/code\u003E is to determine whether the action is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-denied-decisions\u0022\u003EHandling Denied Decisions\u003C/h3\u003E\n\u003Cp\u003EApplications should distinguish between authorization denial, reauthentication requirements, and request failures.\u003C/p\u003E\n\u003Cp\u003EA denied decision may be used to disable an action or display an appropriate message.\u003C/p\u003E\n\u003Cp\u003EA reauthentication requirement may be used to initiate the application\u0027s supported reauthentication flow.\u003C/p\u003E\n\u003Cp\u003EA failed request must not be interpreted as permission to proceed.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFail closed:\u003C/strong\u003E If the authorization decision is unavailable, treat the operation as unavailable in the UI and rely on server-side authorization when the actual operation is requested.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-all-decision-outcomes\u0022\u003EHandle All Decision Outcomes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;delete\u0026quot;,\n Resource = \u0026quot;products\u0026quot;,\n ResourceId = productId.ToString()\n });\n\nif (!result.IsSuccess)\n{\n // The authorization request itself failed.\n}\nelse if (result.Value is { RequiresReauthentication: true })\n{\n // Ask the user to authenticate again.\n}\nelse if (result.Value is { IsAllowed: true })\n{\n // The operation is allowed.\n}\nelse\n{\n // The operation was denied.\n var reason = result.Value?.DenyReason;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn unsuccessful API request and an explicit authorization denial are different outcomes.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.IsSuccess == false\u003C/code\u003E means the authorization request did not complete successfully at the API level.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.Value.IsAllowed == false\u003C/code\u003E means the server returned a denial decision.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.Value.RequiresReauthentication == true\u003C/code\u003E indicates that the caller needs to reestablish authentication.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ETreat failed or unavailable authorization checks as \u003Cstrong\u003Enot authorized\u003C/strong\u003E in the UI.\u003C/p\u003E\n\u003Cp\u003ENever grant access because an authorization request failed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-authorization-checks-in-blazor\u0022\u003E6. Use Authorization Checks in Blazor\u003C/h2\u003E\n\u003Cp\u003EA common use case is controlling whether an action button should be displayed.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Authorization.Contracts\n@inject IUAuthClient UAuthClient\n\n@if (_canCreate)\n{\n \u0026lt;button @onclick=\u0026quot;CreateProductAsync\u0026quot;\u0026gt;Create Product\u0026lt;/button\u0026gt;\n}\n\n@code {\n private bool _canCreate;\n\n protected override async Task OnInitializedAsync()\n {\n var result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;create\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\n _canCreate = result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true };\n }\n\n private Task CreateProductAsync()\n {\n // Invoke the protected application operation.\n return Task.CompletedTask;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis improves the interface by hiding unavailable actions.\u003C/p\u003E\n\u003Cp\u003EHowever, the endpoint handling product creation must still enforce authorization independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-client-checks-are-not-security-boundaries\u0022\u003EWhy Client Checks Are Not Security Boundaries\u003C/h3\u003E\n\u003Cp\u003EA user can bypass a hidden button and send a request directly to an endpoint.\u003C/p\u003E\n\u003Cp\u003ETherefore:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EClient authorization check\n |\n v\nImprove the user experience\n |\n v\nUser submits an operation\n |\n v\nServer independently authorizes the operation\n |\n v\nExecute or deny\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EClient authorization checks should never be the only protection for sensitive operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-roles\u0022\u003E7. Get My Roles\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyRolesAsync\u003C/code\u003E to retrieve roles assigned to the current authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyRolesAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var role in data.Roles.Items)\n {\n Console.WriteLine(role.Name);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserRolesGetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userrolesgetresult\u0022\u003EUserRolesGetResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser whose roles were requested\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoles\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPaginated collection of role assignments\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EEach entry is a \u003Ccode\u003EUserRoleInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userroleinfo\u0022\u003EUserRoleInfo\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant associated with the assignment\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAssignedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssignment timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003EPagination\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyRolesAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned role collection is paginated.\u003C/p\u003E\n\u003Cp\u003EDo not assume that the first page contains all assigned roles.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-permissions\u0022\u003E8. Get My Permissions\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyPermissionsAsync()\u003C/code\u003E to retrieve the permissions assigned to the currently authenticated user through their roles.\u003C/p\u003E\n\u003Cp\u003EUnlike \u003Ccode\u003EGetMyRolesAsync()\u003C/code\u003E, which returns role assignments, this method returns the resulting permission collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyPermissionsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var permission in data.Permissions)\n {\n Console.WriteLine(permission.Value);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method is self-service and does not require administrative permission.\u003C/p\u003E\n\u003Cp\u003EThe user identity and tenant context are derived from the authenticated session. No user identifier or tenant identifier is accepted as a request parameter.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userpermissionsresult\u0022\u003EUserPermissionsResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains the user\u0027s role-derived permissions:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserPermissionsResult\n{\n public required IReadOnlyList\u0026lt;Permission\u0026gt; Permissions { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned collection:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECombines permissions from the user\u0027s assigned roles.\u003C/li\u003E\n\u003Cli\u003ERemoves duplicate permissions.\u003C/li\u003E\n\u003Cli\u003EUses deterministic ordering.\u003C/li\u003E\n\u003Cli\u003EPreserves wildcard permissions such as \u003Ccode\u003Eusers.*\u003C/code\u003E and \u003Ccode\u003E*\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EReturns an empty collection when no permissions are assigned.\u003C/li\u003E\n\u003Cli\u003EReflects the current permissions resolved from the configured authorization stores.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, a user assigned multiple roles may receive:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Ereports.*\nusers.read.admin\nusers.update.admin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe result does not identify which role contributed each permission.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assigned-permissions-vs.authorization-decisions\u0022\u003EAssigned Permissions vs. Authorization Decisions\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EAssigned permissions are not final authorization decisions.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EA user may possess a permission but still be denied access because of an applicable policy, tenant boundary, target restriction, or other security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permissions = await UAuthClient.Authorization.GetMyPermissionsAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis retrieves the user\u0027s assigned permissions.\u003C/p\u003E\n\u003Cp\u003ETo determine whether a specific operation is currently authorized, use \u003Ccode\u003ECheckAsync()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;users.delete.admin\u0026quot;,\n Resource = \u0026quot;users\u0026quot;,\n ResourceId = targetUserKey.Value\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } decision)\n{\n if (decision.IsAllowed)\n {\n // The authorization check allowed this operation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEven a successful client-side authorization check does not replace authorization enforcement at the server-side operation boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022wildcard-permissions\u0022\u003EWildcard Permissions\u003C/h3\u003E\n\u003Cp\u003EWildcard permissions are returned without expansion.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eusers.*\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eremains \u003Ccode\u003Eusers.*\u003C/code\u003E rather than being expanded into individual user-management actions.\u003C/p\u003E\n\u003Cp\u003EThis preserves the stored permission semantics and avoids depending on a potentially incomplete action catalog.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyPermissionsAsync()\u003C/code\u003E is intended for permission inspection, user interfaces, and administrative transparency.\u003C/p\u003E\n\u003Cp\u003EIt must not be used as the sole mechanism for protecting sensitive operations.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for evaluating authorization policies and enforcing access at the actual execution boundary.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/authorization#4-check-authorization\u0022\u003ECheck Authorization\u003C/a\u003E for policy-aware authorization checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-another-users-roles\u0022\u003E9. Get Another User\u0027s Roles\u003C/h2\u003E\n\u003Cp\u003EAdministrative interfaces can retrieve the roles assigned to a specific user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetUserRolesAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var role in data.Roles.Items)\n {\n Console.WriteLine($\u0026quot;{role.Name} \u2014 {role.AssignedAt}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation requires appropriate administrative authorization.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUserKey\u003C/code\u003E identifies the target user, not the current actor.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the current actor may inspect that user\u0027s role assignments.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022query-roles\u0022\u003E10. Query Roles\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EQueryRolesAsync\u003C/code\u003E to retrieve roles with pagination and optional filtering.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var role in page.Items)\n {\n Console.WriteLine(role.Name);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;RoleInfo\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022rolequery\u0022\u003ERoleQuery\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERoleQuery\u003C/code\u003E inherits from \u003Ccode\u003EPageRequest\u003C/code\u003E and adds:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESearch\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional role-name search\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeDeleted\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude deleted roles when supported and authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022search-roles\u0022\u003ESearch Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = \u0026quot;manager\u0026quot;,\n PageNumber = 1,\n PageSize = 10\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022include-deleted-roles\u0022\u003EInclude Deleted Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n IncludeDeleted = true,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIncludeDeleted\u003C/code\u003E only where the application needs administrative access to deleted role records.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for enforcing the query\u0027s permissions and lifecycle rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-roleinfo\u0022\u003E11. Understanding RoleInfo\u003C/h2\u003E\n\u003Cp\u003EEach role returned by the SDK is represented by \u003Ccode\u003ERoleInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPermissions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIReadOnlyCollection\u0026lt;Permission\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned permissions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast update timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var role in page.Items)\n {\n Console.WriteLine($\u0026quot;Role: {role.Name}\u0026quot;);\n\n foreach (var permission in role.Permissions)\n {\n Console.WriteLine($\u0026quot; {permission.Value}\u0026quot;);\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERoleId\u003C/code\u003E is a strongly typed identifier rather than a plain \u003Ccode\u003EGuid\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ERoleId roleId = RoleId.From(existingGuid);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERoleId\u003C/code\u003E for role management requests.\u003C/p\u003E\n\u003Cp\u003EDo not confuse it with \u003Ccode\u003EUserKey\u003C/code\u003E or other identifier types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-role\u0022\u003E12. Create a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateRoleAsync\u003C/code\u003E to create a role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } role)\n{\n Console.WriteLine($\u0026quot;Created: {role.Name}\u0026quot;);\n Console.WriteLine($\u0026quot;Role ID: {role.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RoleInfo\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERole names must be unique within the relevant tenant according to the server\u0027s role-name rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-role-with-permissions\u0022\u003ECreate a Role with Permissions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ECreateRoleRequest\u003C/code\u003E supports an optional initial permission collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;ProductManager\u0026quot;,\n Permissions =\n [\n Permission.From(\u0026quot;products.read\u0026quot;),\n Permission.From(\u0026quot;products.create\u0026quot;),\n Permission.From(\u0026quot;products.update\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to create and configure a role in one operation.\u003C/p\u003E\n\u003Cp\u003EThe server validates the requested permissions and whether the caller is authorized to create the role.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-read-only-role\u0022\u003ECreate a Read-Only Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Auditor\u0026quot;,\n Permissions =\n [\n Permission.From(\u0026quot;reports.read\u0026quot;),\n Permission.From(\u0026quot;products.read\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse role names that describe the responsibility or capability represented by the role.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022rename-a-role\u0022\u003E13. Rename a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERenameRoleAsync\u003C/code\u003E to change a role\u0027s name.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RenameRoleAsync(\n new RenameRoleRequest\n {\n Id = roleId,\n Name = \u0026quot;SeniorManager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role renamed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERenameRoleRequest\u003C/code\u003E requires:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EId\u003C/code\u003E: The role\u0027s \u003Ccode\u003ERoleId\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EName\u003C/code\u003E: The new role name.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe operation may fail if another role already uses the requested name.\u003C/p\u003E\n\u003Cp\u003ERole renaming does not mean that permissions are automatically changed.\u003C/p\u003E\n\u003Cp\u003EAfter a successful rename, the SDK publishes an \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E state event.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-role-permissions\u0022\u003E14. Set Role Permissions\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E to configure a role\u0027s permissions.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = roleId,\n Permissions =\n [\n Permission.From(\u0026quot;products.read\u0026quot;),\n Permission.From(\u0026quot;products.create\u0026quot;),\n Permission.From(\u0026quot;products.update\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole to modify\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPermissions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew permission collection\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-this-is-a-replacement-operation\u0022\u003EImportant: This Is a Replacement Operation\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E replaces the role\u0027s existing permission collection.\u003C/p\u003E\n\u003Cp\u003EIt does not append permissions to the current collection.\u003C/p\u003E\n\u003Cp\u003EFor example, suppose a role currently contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.read\nproducts.create\nproducts.update\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESubmitting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermissions =\n[\n Permission.From(\u0026quot;products.read\u0026quot;)\n]\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Emeans the intended new permission collection contains only:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe other permissions are removed from the role.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022safely-add-a-permission-to-an-existing-role\u0022\u003ESafely Add a Permission to an Existing Role\u003C/h3\u003E\n\u003Cp\u003ETo preserve existing permissions, first retrieve the role and construct the complete desired collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar query = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = \u0026quot;ProductManager\u0026quot;,\n PageNumber = 1,\n PageSize = 50\n });\n\nif (query.IsSuccess \u0026amp;\u0026amp; query.Value is { } page)\n{\n var role = page.Items.FirstOrDefault(x =\u0026gt; x.Name == \u0026quot;ProductManager\u0026quot;);\n\n if (role is not null)\n {\n var permissions = role.Permissions.Append(Permission.From(\u0026quot;products.delete\u0026quot;)).Distinct().ToArray();\n\n var result =\n await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = role.Id,\n Permissions = permissions\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a read-modify-write pattern, not an atomic append operation.\u003C/p\u003E\n\u003Cp\u003EIf multiple administrators edit the same role concurrently, the last successful replacement may overwrite another administrator\u0027s changes.\u003C/p\u003E\n\u003Cp\u003EApplications requiring concurrency-safe editing should implement suitable coordination or version checking at the application level.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-permission\u0022\u003ERemove a Permission\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar updatedPermissions = role.Permissions.Where(x =\u0026gt; x.Value != \u0026quot;products.delete\u0026quot;).ToArray();\n\nvar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = role.Id,\n Permissions = updatedPermissions\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAgain, submit the complete desired permission set.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-all-permissions\u0022\u003EClear All Permissions\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = roleId,\n Permissions = []\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn empty collection requests that the role have no permissions.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the operation is permitted.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-a-role-to-a-user\u0022\u003E15. Assign a Role to a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAssignRoleToUserAsync\u003C/code\u003E to assign an existing role to a user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role assigned.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETarget user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting role name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe role must exist, and the current actor must be authorized to assign it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-multiple-roles\u0022\u003EAssign Multiple Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eforeach (var roleName in new[] { \u0026quot;Manager\u0026quot;, \u0026quot;Auditor\u0026quot; })\n{\n var result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = roleName\n });\n\n if (!result.IsSuccess)\n {\n // Handle the failed assignment.\n break;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach call is a separate operation.\u003C/p\u003E\n\u003Cp\u003EThis example does not provide transactional, all-or-nothing behavior across the entire collection.\u003C/p\u003E\n\u003Cp\u003EFor complex provisioning workflows, consider whether the application needs a server-side orchestration operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-role-from-a-user\u0022\u003E16. Remove a Role from a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERemoveRoleFromUserAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(\n new RemoveRoleRequest\n {\n UserKey = userKey,\n RoleName = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role removed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemoving a role changes the user\u0027s role assignments.\u003C/p\u003E\n\u003Cp\u003EIt does not delete the role itself.\u003C/p\u003E\n\u003Cp\u003EOther users assigned to that role are unaffected by this particular operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-effective-permissions-may-remain\u0022\u003EImportant: Effective Permissions May Remain\u003C/h3\u003E\n\u003Cp\u003EA user may receive the same permission through multiple roles.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Manager\n | \u002B-- products.read\n |\n \u002B-- Auditor\n \u002B-- products.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemoving \u003Ccode\u003EManager\u003C/code\u003E does not necessarily remove the user\u0027s ability to read products because \u003Ccode\u003EAuditor\u003C/code\u003E still provides \u003Ccode\u003Eproducts.read\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ETo determine whether the user may perform an action, rely on the server\u0027s authorization decision rather than inspecting one role assignment in isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-role\u0022\u003E17. Delete a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteRoleAsync\u003C/code\u003E to delete a role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request supports a deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe availability and effect of each deletion mode are determined by server-side lifecycle rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022roledeleteresult\u0022\u003ERoleDeleteResult\u003C/h3\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RoleDeleteResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeleted role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemovedAssignments\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumber of user-role assignments removed\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EApplied deletion mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeletedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeletion timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-the-result\u0022\u003EInspect the Result\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } deleted)\n{\n Console.WriteLine($\u0026quot;Removed assignments: {deleted.RemovedAssignments}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDeleting a role removes its assignments from users.\u003C/p\u003E\n\u003Cp\u003EThis can affect multiple users at once.\u003C/p\u003E\n\u003Cp\u003EFor that reason, role deletion should be presented as a sensitive administrative operation with appropriate confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-role-management-page\u0022\u003E18. Build a Role Management Page\u003C/h2\u003E\n\u003Cp\u003EThe following Blazor example demonstrates querying roles, creating a role, and deleting a selected role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Authorization.Contracts\n@using CodeBeam.UltimateAuth.Core.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Role Management\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_search\u0026quot; placeholder=\u0026quot;Search roles\u0026quot; /\u0026gt;\n\n\u0026lt;button @onclick=\u0026quot;LoadRolesAsync\u0026quot;\u0026gt;Search\u0026lt;/button\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading roles...\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var role in _roles)\n {\n \u0026lt;div @key=\u0026quot;role.Id\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@role.Name\u0026lt;/strong\u0026gt;\n \u0026lt;span\u0026gt;@role.Permissions.Count permissions\u0026lt;/span\u0026gt;\n \u0026lt;button @onclick=\u0026quot;() =\u0026gt; DeleteRoleAsync(role.Id)\u0026quot;\u0026gt;Delete\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n\u0026lt;h4\u0026gt;Create Role\u0026lt;/h4\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newRoleName\u0026quot; placeholder=\u0026quot;Role name\u0026quot; /\u0026gt;\n\u0026lt;button @onclick=\u0026quot;CreateRoleAsync\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Create\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private IReadOnlyList\u0026lt;RoleInfo\u0026gt; _roles = [];\n\n private string _search = \u0026quot;\u0026quot;;\n private string _newRoleName = \u0026quot;\u0026quot;;\n private string? _message;\n\n private bool _loading = true;\n private bool _busy;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadRolesAsync();\n }\n\n private async Task LoadRolesAsync()\n {\n _loading = true;\n\n try\n {\n var result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = _search,\n PageNumber = 1,\n PageSize = 50\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _roles = page.Items;\n }\n else\n {\n _message = \u0026quot;Unable to load roles.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The authorization service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task CreateRoleAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_newRoleName))\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = _newRoleName\n });\n\n if (result.IsSuccess)\n {\n _newRoleName = \u0026quot;\u0026quot;;\n await LoadRolesAsync();\n }\n else\n {\n _message = \u0026quot;Unable to create role.\u0026quot;;\n }\n }\n finally\n {\n _busy = false;\n }\n }\n\n private async Task DeleteRoleAsync(RoleId roleId)\n {\n // Add a confirmation dialog before deletion in production.\n\n var result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\n if (result.IsSuccess)\n {\n await LoadRolesAsync();\n }\n else\n {\n _message = \u0026quot;Unable to delete role.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the SDK integration rather than a complete production administration interface.\u003C/p\u003E\n\u003Cp\u003EA production implementation should include pagination, role assignment controls, permission editing, deletion confirmation, and appropriate loading and error handling for every operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-user-role-assignment-interface\u0022\u003E19. Build a User Role Assignment Interface\u003C/h2\u003E\n\u003Cp\u003EAn administrative user details page can display assigned roles and allow administrators to add or remove them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022load-user-roles\u0022\u003ELoad User Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetUserRolesAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 50\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n var assignedRoles = data.Roles.Items.ToList();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-a-selected-role\u0022\u003EAssign a Selected Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = selectedRoleName\n });\n\nif (result.IsSuccess)\n{\n // Reload the user\u0027s assigned roles.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-selected-role\u0022\u003ERemove a Selected Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(\n new RemoveRoleRequest\n {\n UserKey = userKey,\n RoleName = selectedRoleName\n });\n\nif (result.IsSuccess)\n{\n // Reload the user\u0027s assigned roles.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis pattern works well alongside \u003Ccode\u003EUAuthClient.Users\u003C/code\u003E for building a unified user administration interface.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-state-events\u0022\u003E20. Authorization State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthAuthorizationClient\u003C/code\u003E publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.AuthorizationChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eafter successful authorization mutations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPublishes \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAssignRoleToUserAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemoveRoleFromUserAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERenameRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreateRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGetMyRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGetUserRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe event follows the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can be used to refresh navigation menus, role assignment views, or permission-sensitive UI.\u003C/p\u003E\n\u003Cp\u003EHowever, it is a \u003Cstrong\u003Elocal client event\u003C/strong\u003E, not a guarantee that all active clients or distributed caches have immediately observed the change.\u003C/p\u003E\n\u003Cp\u003EFor example, an administrator assigning a role to another user does not automatically mean that user\u0027s open browser receives the administrator\u0027s local event.\u003C/p\u003E\n\u003Cp\u003EApplications requiring cross-client updates should provide an appropriate synchronization mechanism.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-changes-and-active-sessions\u0022\u003E21. Authorization Changes and Active Sessions\u003C/h2\u003E\n\u003Cp\u003ERole and permission changes may affect active users.\u003C/p\u003E\n\u003Cp\u003EHowever, the timing of those changes depends on how authorization data is resolved and cached by the server.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EAn administrator removes a role.\u003C/li\u003E\n\u003Cli\u003EThe server persists the new role assignment state.\u003C/li\u003E\n\u003Cli\u003EA subsequent authorization evaluation uses the effective authorization state available to the server.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EIf the application caches authorization information, changes may not become visible everywhere immediately.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a client event invalidates every active session or refreshes every distributed authorization cache.\u003C/p\u003E\n\u003Cp\u003EFor sensitive operations, authorization should be evaluated against sufficiently current server-side state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022roles-are-not-sessions\u0022\u003ERoles Are Not Sessions\u003C/h3\u003E\n\u003Cp\u003ERemoving a role and revoking a session are different operations.\u003C/p\u003E\n\u003Cp\u003ERemoving a role changes authorization data.\u003C/p\u003E\n\u003Cp\u003ERevoking a session changes whether an authentication session remains valid.\u003C/p\u003E\n\u003Cp\u003EApplications may combine these operations when security policy requires immediate termination of access, but the concepts should remain separate.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-authorization\u0022\u003E22. Multi-Tenant Authorization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth authorization operations are scoped to the current tenant context.\u003C/p\u003E\n\u003Cp\u003ERole names are unique within the relevant tenant, and role assignments belong to the applicable tenant.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ETenant A\n |\n \u002B-- Manager\n | \u002B-- products.read\n |\n \u002B-- User A\n\nTenant B\n |\n \u002B-- Manager\n | \u002B-- reports.read\n |\n \u002B-- User B\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same role name may represent different permission sets in different tenants.\u003C/p\u003E\n\u003Cp\u003EThe client does not gain cross-tenant access merely by providing another user\u0027s \u003Ccode\u003EUserKey\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe server must enforce tenant isolation and administrative authorization.\u003C/p\u003E\n\u003Cp\u003EWhen implementing multi-tenant administration, do not assume role names or permission configurations are globally shared.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-authorization-errors\u0022\u003E23. Handling Authorization Errors\u003C/h2\u003E\n\u003Cp\u003EAuthorization methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-role-creation-errors\u0022\u003EHandle Role Creation Errors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role created.\n}\nelse if (result.Http.IsConflict)\n{\n // A conflicting role may already exist.\n}\nelse if (result.Http.IsForbidden)\n{\n // Caller lacks permission.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-authorization-check-failures\u0022\u003EHandle Authorization Check Failures\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;update\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\nvar allowed = result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true };\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis fail-closed pattern is appropriate when determining whether the UI should expose a protected operation.\u003C/p\u003E\n\u003Cp\u003EUse the server\u0027s structured error information for diagnostics where appropriate.\u003C/p\u003E\n\u003Cp\u003EDo not expose sensitive policy internals unnecessarily.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations-1\u0022\u003E24. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-authorization-is-authoritative\u0022\u003EServer Authorization Is Authoritative\u003C/h3\u003E\n\u003Cp\u003EClient-side checks improve usability but cannot protect an endpoint by themselves.\u003C/p\u003E\n\u003Cp\u003EEvery protected operation must enforce authorization on the server.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022follow-least-privilege\u0022\u003EFollow Least Privilege\u003C/h3\u003E\n\u003Cp\u003EAssign only the permissions a role actually needs.\u003C/p\u003E\n\u003Cp\u003EAvoid using \u003Ccode\u003E*\u003C/code\u003E or broad prefix wildcards for ordinary users.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003ERole creation, permission replacement, role assignment, and role deletion are privileged operations.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize each mutation independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treat-permission-replacement-carefully\u0022\u003ETreat Permission Replacement Carefully\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E replaces the entire permission collection.\u003C/p\u003E\n\u003Cp\u003EAlways inspect the existing role configuration before submitting changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-reauthentication-requirements\u0022\u003EHandle Reauthentication Requirements\u003C/h3\u003E\n\u003Cp\u003EAn authorization decision can indicate \u003Ccode\u003ERequiresReauthentication\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EApplications should handle this state explicitly rather than treating it as an ordinary permission denial.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022respect-tenant-boundaries\u0022\u003ERespect Tenant Boundaries\u003C/h3\u003E\n\u003Cp\u003ERoles and assignments must remain within their authorized tenant context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-infer-effective-access-from-one-role\u0022\u003EDo Not Infer Effective Access from One Role\u003C/h3\u003E\n\u003Cp\u003EA user can have multiple roles, overlapping permissions, and additional policy restrictions.\u003C/p\u003E\n\u003Cp\u003EUse the server\u0027s authorization decision when determining whether an action is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-for-cached-authorization-state\u0022\u003EAccount for Cached Authorization State\u003C/h3\u003E\n\u003Cp\u003EPermission changes may not propagate instantly to every active client or cached authorization context.\u003C/p\u003E\n\u003Cp\u003EDesign sensitive operations around server-side enforcement and appropriate cache invalidation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022putting-everything-together\u0022\u003E25. Putting Everything Together\u003C/h2\u003E\n\u003Cp\u003EA typical administrative authorization workflow may look like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ECreate role\n |\n v\nConfigure permissions\n |\n v\nAssign role to user\n |\n v\nUser requests protected action\n |\n v\nServer evaluates authorization\n |\n \u002B-- Allow --\u0026gt; Execute operation\n |\n \u002B-- Deny --\u0026gt; Reject operation\n |\n \u002B-- Reauthentication required\n |\n v\n Authenticate again\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUltimateAuth separates role management from authorization enforcement.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to provide flexible administrative interfaces while keeping access decisions under server control.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/policies-access-control\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E \u2014 Understand application policies and access rules.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/authorization-pipeline-internals\u0022\u003EAuthorization Pipeline Internals\u003C/a\u003E \u2014 Explore how authorization decisions are evaluated.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage user accounts and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage authenticated sessions and devices.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session validity and security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a unified client API for managing roles, assigning permissions, controlling user-role relationships, and requesting authorization decisions from the server.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Authorization\u003C/code\u003E, applications can build administrative authorization interfaces and check whether the current user is allowed to perform a particular action.\u003C/p\u003E\n\u003Cp\u003EAuthorization is enforced on the server. Client-side permission checks are useful for improving the user experience, but they do not replace authorization at the endpoint or application-service level.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EIAuthorizationClient\u003C/code\u003E API, practical role management workflows, permission patterns, and secure client integration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-authorization\u0022\u003E1. Understanding Authorization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth authorization combines several concepts:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConcept\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERoles\u003C/td\u003E\n\u003Ctd\u003ENamed collections of permissions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPermissions\u003C/td\u003E\n\u003Ctd\u003EFine-grained capabilities represented by normalized values\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser-role assignments\u003C/td\u003E\n\u003Ctd\u003EAssociations between users and roles\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPolicies\u003C/td\u003E\n\u003Ctd\u003EServer-side rules that participate in access decisions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthorization context\u003C/td\u003E\n\u003Ctd\u003EInformation about the actor, resource, action, and current security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA typical authorization relationship looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Manager\n | |\n | \u002B-- products.read\n | \u002B-- products.create\n | \u002B-- products.update\n |\n \u002B-- Auditor\n |\n \u002B-- reports.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA user can have multiple roles, and each role can contain multiple permissions.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ehaving a permission does not automatically guarantee access\u003C/strong\u003E. The server\u0027s authorization pipeline may apply additional requirements, such as resource ownership, session validity, tenant isolation, or application policies.\u003C/p\u003E\n\u003Cp\u003EFor the internal decision model, see \u003Ca href=\u0022/docs/security/authorization-pipeline\u0022\u003EAuthorization Pipeline Internals\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-authorization-client\u0022\u003E2. Accessing the Authorization Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAuthorization operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Authorization\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property exposes \u003Ccode\u003EIAuthorizationClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface provides three main groups of operations:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EAuthorization checks:\u003C/strong\u003E Evaluate whether the current user may perform an action.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERole management:\u003C/strong\u003E Create, query, rename, configure, and delete roles.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUser-role management:\u003C/strong\u003E Retrieve, assign, and remove user roles.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe SDK sends requests to the corresponding UltimateAuth endpoints and returns structured \u003Ccode\u003EUAuthResult\u003C/code\u003E values.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-permissions\u0022\u003E3. Understanding Permissions\u003C/h2\u003E\n\u003Cp\u003EA permission is represented by the \u003Ccode\u003EPermission\u003C/code\u003E value type.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permission = Permission.From(\u0026quot;products.create\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation normalizes permission values by trimming surrounding whitespace and converting the value to lowercase.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permission = Permission.From(\u0026quot; PRODUCTS.CREATE \u0026quot;);\n\nConsole.WriteLine(permission.Value);\n// products.create\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe constructor rejects null, empty, and whitespace-only values.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022permission-patterns\u0022\u003EPermission Patterns\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth recognizes three important forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPattern\u003C/th\u003E\n\u003Cth\u003EExample\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EExact\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eproducts.create\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EA specific permission\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPrefix wildcard\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eproducts.*\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermissions within the corresponding prefix\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EGlobal wildcard\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E*\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnrestricted permission matching within the applicable authorization rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ECreate them using:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar exact = Permission.From(\u0026quot;products.create\u0026quot;);\n\nvar prefix = Permission.From(\u0026quot;products.*\u0026quot;);\n\nvar global = Permission.Wildcard;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe type also exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epermission.IsWildcard;\npermission.IsPrefix;\npermission.IsValid;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022permission-normalization\u0022\u003EPermission Normalization\u003C/h3\u003E\n\u003Cp\u003EThe following values normalize to the same representation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.From(\u0026quot;Products.Create\u0026quot;);\nPermission.From(\u0026quot;products.create\u0026quot;);\nPermission.From(\u0026quot; PRODUCTS.CREATE \u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAll produce:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.create\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse consistent permission names across application services, policies, and role configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022wildcards-are-powerful\u0022\u003EWildcards Are Powerful\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.From(\u0026quot;users.*\u0026quot;)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Emay grant broad access to operations within the \u003Ccode\u003Eusers\u003C/code\u003E permission prefix.\u003C/p\u003E\n\u003Cp\u003EThe global wildcard:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermission.Wildcard\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eis especially sensitive.\u003C/p\u003E\n\u003Cp\u003EAvoid assigning broad wildcard permissions merely to simplify development. Use least-privilege permissions for ordinary application roles.\u003C/p\u003E\n\u003Cp\u003EA wildcard is not a substitute for satisfying independent security policies, authentication requirements, or tenant restrictions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-authorization\u0022\u003E4. Check Authorization\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E evaluates whether the current authenticated user is authorized to perform a specific action against a resource.\u003C/p\u003E\n\u003Cp\u003EIt provides an \u003Cstrong\u003Eon-demand, server-side authorization decision\u003C/strong\u003E based on the current authentication context, permissions, roles, and applicable authorization policies.\u003C/p\u003E\n\u003Cp\u003EUnlike operations that perform protected actions, \u003Ccode\u003ECheckAsync\u003C/code\u003E only evaluates access. It does not execute the requested operation, modify permissions, grant access, or reserve authorization for a future request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022when-to-use-checkasync\u0022\u003EWhen to Use CheckAsync\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAsync\u003C/code\u003E when your application needs to:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDetermine whether an authenticated user can perform an action before presenting it in the UI.\u003C/li\u003E\n\u003Cli\u003EEnable, disable, or hide controls based on the current authorization decision.\u003C/li\u003E\n\u003Cli\u003EExplain why an operation is unavailable, using the returned denial reason where appropriate.\u003C/li\u003E\n\u003Cli\u003EDetermine whether an operation requires reauthentication.\u003C/li\u003E\n\u003Cli\u003EEvaluate resource-specific access when the applicable policies support it.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, an application may check whether the current user can delete a particular order before displaying the Delete button.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-checkasync-does-not-guarantee\u0022\u003EWhat CheckAsync Does Not Guarantee\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EA successful authorization check is not a guarantee that a subsequent operation will succeed.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EAuthorization is evaluated against the security context available at the time of the check. That context may change before the actual operation is performed.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAn administrator may revoke the user\u0027s permissions.\u003C/li\u003E\n\u003Cli\u003EThe user\u0027s session may expire or become invalid.\u003C/li\u003E\n\u003Cli\u003EThe target resource may be deleted or modified.\u003C/li\u003E\n\u003Cli\u003EThe resource\u0027s ownership or tenant context may change.\u003C/li\u003E\n\u003Cli\u003EAdditional business rules may prevent the operation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe protected operation must independently enforce authorization using its current context.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever use a previous \u003Ccode\u003ECheckAsync\u003C/code\u003E result as proof of authorization for a later request.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E is primarily a developer-experience and user-experience API. It helps applications understand and present authorization decisions without replacing the security enforcement performed by protected endpoints and application services.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAsync\u003C/code\u003E to ask the server whether the current user is authorized to perform an action.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;read\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true })\n{\n // The server authorized this action.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;AuthorizationResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorizationcheckrequest\u0022\u003EAuthorizationCheckRequest\u003C/h3\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAction\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResource\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResource being accessed\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResourceId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional identifier for a specific resource instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EBoth \u003Ccode\u003EAction\u003C/code\u003E and \u003Ccode\u003EResource\u003C/code\u003E are required.\u003C/p\u003E\n\u003Cp\u003EThe action and resource values must follow the conventions understood by your server-side authorization configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-a-specific-resource\u0022\u003ECheck a Specific Resource\u003C/h3\u003E\n\u003Cp\u003EFor example, check access to a particular product:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;update\u0026quot;,\n Resource = \u0026quot;products\u0026quot;,\n ResourceId = productId.ToString()\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EProviding \u003Ccode\u003EResourceId\u003C/code\u003E allows the server to evaluate resource-specific rules where supported by the configured authorization pipeline.\u003C/p\u003E\n\u003Cp\u003EFor example, the application may distinguish between updating any product and updating a product owned by the current user.\u003C/p\u003E\n\u003Cp\u003EDo not assume that sending \u003Ccode\u003EResourceId\u003C/code\u003E automatically enables ownership checking. The relevant server-side policy must implement the required rule.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-authorization-results\u0022\u003E5. Understanding Authorization Results\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E returns an \u003Ccode\u003EAuthorizationResult\u003C/code\u003E with the following properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the request was authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresReauthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the decision indicates reauthentication is required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDenyReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional denial reason code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-decisions-vs.http-results\u0022\u003EAuthorization Decisions vs. HTTP Results\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E separates the outcome of the API request from the authorization decision itself.\u003C/p\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;AuthorizationResult\u0026gt;\u003C/code\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthResult.IsSuccess\u003C/code\u003E indicates whether the authorization check request completed successfully.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.IsAllowed\u003C/code\u003E indicates whether the requested action is currently authorized.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.RequiresReauthentication\u003C/code\u003E indicates whether additional authentication is required.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAuthorizationResult.DenyReason\u003C/code\u003E provides an optional reason for denial.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA denied authorization decision is a valid result of a successful authorization check.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScenario\u003C/th\u003E\n\u003Cth\u003EHTTP Status\u003C/th\u003E\n\u003Cth\u003EAuthorization Result\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAction allowed\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed = true\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAction denied\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAllowed = false\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EReauthentication required\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E200 OK\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERequiresReauthentication = true\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECaller not authenticated\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E401 Unauthorized\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInvalid request\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E400 Bad Request\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServer-side evaluation failure\u003C/td\u003E\n\u003Ctd\u003EAppropriate error status\u003C/td\u003E\n\u003Ctd\u003ENo authorization decision\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EHTTP 200 means the authorization evaluation completed successfully. It does not mean access was granted.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EOnly authenticated callers can use the current \u003Ccode\u003ECheckAsync\u003C/code\u003E endpoint. However, callers do not need permission to perform the action they are checking.\u003C/p\u003E\n\u003Cp\u003EThis distinction is intentional: the purpose of \u003Ccode\u003ECheckAsync\u003C/code\u003E is to determine whether the action is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-denied-decisions\u0022\u003EHandling Denied Decisions\u003C/h3\u003E\n\u003Cp\u003EApplications should distinguish between authorization denial, reauthentication requirements, and request failures.\u003C/p\u003E\n\u003Cp\u003EA denied decision may be used to disable an action or display an appropriate message.\u003C/p\u003E\n\u003Cp\u003EA reauthentication requirement may be used to initiate the application\u0027s supported reauthentication flow.\u003C/p\u003E\n\u003Cp\u003EA failed request must not be interpreted as permission to proceed.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFail closed:\u003C/strong\u003E If the authorization decision is unavailable, treat the operation as unavailable in the UI and rely on server-side authorization when the actual operation is requested.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-all-decision-outcomes\u0022\u003EHandle All Decision Outcomes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;delete\u0026quot;,\n Resource = \u0026quot;products\u0026quot;,\n ResourceId = productId.ToString()\n });\n\nif (!result.IsSuccess)\n{\n // The authorization request itself failed.\n}\nelse if (result.Value is { RequiresReauthentication: true })\n{\n // Ask the user to authenticate again.\n}\nelse if (result.Value is { IsAllowed: true })\n{\n // The operation is allowed.\n}\nelse\n{\n // The operation was denied.\n var reason = result.Value?.DenyReason;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn unsuccessful API request and an explicit authorization denial are different outcomes.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.IsSuccess == false\u003C/code\u003E means the authorization request did not complete successfully at the API level.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.Value.IsAllowed == false\u003C/code\u003E means the server returned a denial decision.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003Eresult.Value.RequiresReauthentication == true\u003C/code\u003E indicates that the caller needs to reestablish authentication.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ETreat failed or unavailable authorization checks as \u003Cstrong\u003Enot authorized\u003C/strong\u003E in the UI.\u003C/p\u003E\n\u003Cp\u003ENever grant access because an authorization request failed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-authorization-checks-in-blazor\u0022\u003E6. Use Authorization Checks in Blazor\u003C/h2\u003E\n\u003Cp\u003EA common use case is controlling whether an action button should be displayed.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Authorization.Contracts\n@inject IUAuthClient UAuthClient\n\n@if (_canCreate)\n{\n \u0026lt;button @onclick=\u0026quot;CreateProductAsync\u0026quot;\u0026gt;Create Product\u0026lt;/button\u0026gt;\n}\n\n@code {\n private bool _canCreate;\n\n protected override async Task OnInitializedAsync()\n {\n var result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;create\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\n _canCreate = result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true };\n }\n\n private Task CreateProductAsync()\n {\n // Invoke the protected application operation.\n return Task.CompletedTask;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis improves the interface by hiding unavailable actions.\u003C/p\u003E\n\u003Cp\u003EHowever, the endpoint handling product creation must still enforce authorization independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-client-checks-are-not-security-boundaries\u0022\u003EWhy Client Checks Are Not Security Boundaries\u003C/h3\u003E\n\u003Cp\u003EA user can bypass a hidden button and send a request directly to an endpoint.\u003C/p\u003E\n\u003Cp\u003ETherefore:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EClient authorization check\n |\n v\nImprove the user experience\n |\n v\nUser submits an operation\n |\n v\nServer independently authorizes the operation\n |\n v\nExecute or deny\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EClient authorization checks should never be the only protection for sensitive operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-roles\u0022\u003E7. Get My Roles\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyRolesAsync\u003C/code\u003E to retrieve roles assigned to the current authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyRolesAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var role in data.Roles.Items)\n {\n Console.WriteLine(role.Name);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserRolesGetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userrolesgetresult\u0022\u003EUserRolesGetResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser whose roles were requested\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoles\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPaginated collection of role assignments\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EEach entry is a \u003Ccode\u003EUserRoleInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userroleinfo\u0022\u003EUserRoleInfo\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant associated with the assignment\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAssignedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssignment timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003EPagination\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyRolesAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned role collection is paginated.\u003C/p\u003E\n\u003Cp\u003EDo not assume that the first page contains all assigned roles.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-permissions\u0022\u003E8. Get My Permissions\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyPermissionsAsync()\u003C/code\u003E to retrieve the permissions assigned to the currently authenticated user through their roles.\u003C/p\u003E\n\u003Cp\u003EUnlike \u003Ccode\u003EGetMyRolesAsync()\u003C/code\u003E, which returns role assignments, this method returns the resulting permission collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetMyPermissionsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var permission in data.Permissions)\n {\n Console.WriteLine(permission.Value);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method is self-service and does not require administrative permission.\u003C/p\u003E\n\u003Cp\u003EThe user identity and tenant context are derived from the authenticated session. No user identifier or tenant identifier is accepted as a request parameter.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userpermissionsresult\u0022\u003EUserPermissionsResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains the user\u0027s role-derived permissions:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserPermissionsResult\n{\n public required IReadOnlyList\u0026lt;Permission\u0026gt; Permissions { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned collection:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECombines permissions from the user\u0027s assigned roles.\u003C/li\u003E\n\u003Cli\u003ERemoves duplicate permissions.\u003C/li\u003E\n\u003Cli\u003EUses deterministic ordering.\u003C/li\u003E\n\u003Cli\u003EPreserves wildcard permissions such as \u003Ccode\u003Eusers.*\u003C/code\u003E and \u003Ccode\u003E*\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EReturns an empty collection when no permissions are assigned.\u003C/li\u003E\n\u003Cli\u003EReflects the current permissions resolved from the configured authorization stores.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, a user assigned multiple roles may receive:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Ereports.*\nusers.read.admin\nusers.update.admin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe result does not identify which role contributed each permission.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assigned-permissions-vs.authorization-decisions\u0022\u003EAssigned Permissions vs. Authorization Decisions\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EAssigned permissions are not final authorization decisions.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EA user may possess a permission but still be denied access because of an applicable policy, tenant boundary, target restriction, or other security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar permissions = await UAuthClient.Authorization.GetMyPermissionsAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis retrieves the user\u0027s assigned permissions.\u003C/p\u003E\n\u003Cp\u003ETo determine whether a specific operation is currently authorized, use \u003Ccode\u003ECheckAsync()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;users.delete.admin\u0026quot;,\n Resource = \u0026quot;users\u0026quot;,\n ResourceId = targetUserKey.Value\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } decision)\n{\n if (decision.IsAllowed)\n {\n // The authorization check allowed this operation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEven a successful client-side authorization check does not replace authorization enforcement at the server-side operation boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022wildcard-permissions\u0022\u003EWildcard Permissions\u003C/h3\u003E\n\u003Cp\u003EWildcard permissions are returned without expansion.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eusers.*\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eremains \u003Ccode\u003Eusers.*\u003C/code\u003E rather than being expanded into individual user-management actions.\u003C/p\u003E\n\u003Cp\u003EThis preserves the stored permission semantics and avoids depending on a potentially incomplete action catalog.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyPermissionsAsync()\u003C/code\u003E is intended for permission inspection, user interfaces, and administrative transparency.\u003C/p\u003E\n\u003Cp\u003EIt must not be used as the sole mechanism for protecting sensitive operations.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for evaluating authorization policies and enforcing access at the actual execution boundary.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/authorization#4-check-authorization\u0022\u003ECheck Authorization\u003C/a\u003E for policy-aware authorization checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-another-users-roles\u0022\u003E9. Get Another User\u0027s Roles\u003C/h2\u003E\n\u003Cp\u003EAdministrative interfaces can retrieve the roles assigned to a specific user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetUserRolesAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n foreach (var role in data.Roles.Items)\n {\n Console.WriteLine($\u0026quot;{role.Name} \u2014 {role.AssignedAt}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation requires appropriate administrative authorization.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUserKey\u003C/code\u003E identifies the target user, not the current actor.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the current actor may inspect that user\u0027s role assignments.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022query-roles\u0022\u003E10. Query Roles\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EQueryRolesAsync\u003C/code\u003E to retrieve roles with pagination and optional filtering.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var role in page.Items)\n {\n Console.WriteLine(role.Name);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;RoleInfo\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022rolequery\u0022\u003ERoleQuery\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERoleQuery\u003C/code\u003E inherits from \u003Ccode\u003EPageRequest\u003C/code\u003E and adds:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESearch\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional role-name search\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeDeleted\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude deleted roles when supported and authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022search-roles\u0022\u003ESearch Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = \u0026quot;manager\u0026quot;,\n PageNumber = 1,\n PageSize = 10\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022include-deleted-roles\u0022\u003EInclude Deleted Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n IncludeDeleted = true,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIncludeDeleted\u003C/code\u003E only where the application needs administrative access to deleted role records.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for enforcing the query\u0027s permissions and lifecycle rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-roleinfo\u0022\u003E11. Understanding RoleInfo\u003C/h2\u003E\n\u003Cp\u003EEach role returned by the SDK is represented by \u003Ccode\u003ERoleInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPermissions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIReadOnlyCollection\u0026lt;Permission\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssigned permissions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast update timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var role in page.Items)\n {\n Console.WriteLine($\u0026quot;Role: {role.Name}\u0026quot;);\n\n foreach (var permission in role.Permissions)\n {\n Console.WriteLine($\u0026quot; {permission.Value}\u0026quot;);\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERoleId\u003C/code\u003E is a strongly typed identifier rather than a plain \u003Ccode\u003EGuid\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ERoleId roleId = RoleId.From(existingGuid);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERoleId\u003C/code\u003E for role management requests.\u003C/p\u003E\n\u003Cp\u003EDo not confuse it with \u003Ccode\u003EUserKey\u003C/code\u003E or other identifier types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-role\u0022\u003E12. Create a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateRoleAsync\u003C/code\u003E to create a role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } role)\n{\n Console.WriteLine($\u0026quot;Created: {role.Name}\u0026quot;);\n Console.WriteLine($\u0026quot;Role ID: {role.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RoleInfo\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERole names must be unique within the relevant tenant according to the server\u0027s role-name rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-role-with-permissions\u0022\u003ECreate a Role with Permissions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ECreateRoleRequest\u003C/code\u003E supports an optional initial permission collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;ProductManager\u0026quot;,\n Permissions =\n [\n Permission.From(\u0026quot;products.read\u0026quot;),\n Permission.From(\u0026quot;products.create\u0026quot;),\n Permission.From(\u0026quot;products.update\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to create and configure a role in one operation.\u003C/p\u003E\n\u003Cp\u003EThe server validates the requested permissions and whether the caller is authorized to create the role.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-read-only-role\u0022\u003ECreate a Read-Only Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Auditor\u0026quot;,\n Permissions =\n [\n Permission.From(\u0026quot;reports.read\u0026quot;),\n Permission.From(\u0026quot;products.read\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse role names that describe the responsibility or capability represented by the role.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022rename-a-role\u0022\u003E13. Rename a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERenameRoleAsync\u003C/code\u003E to change a role\u0027s name.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RenameRoleAsync(\n new RenameRoleRequest\n {\n Id = roleId,\n Name = \u0026quot;SeniorManager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role renamed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERenameRoleRequest\u003C/code\u003E requires:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EId\u003C/code\u003E: The role\u0027s \u003Ccode\u003ERoleId\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EName\u003C/code\u003E: The new role name.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe operation may fail if another role already uses the requested name.\u003C/p\u003E\n\u003Cp\u003ERole renaming does not mean that permissions are automatically changed.\u003C/p\u003E\n\u003Cp\u003EAfter a successful rename, the SDK publishes an \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E state event.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-role-permissions\u0022\u003E14. Set Role Permissions\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E to configure a role\u0027s permissions.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = roleId,\n Permissions =\n [\n Permission.From(\u0026quot;products.read\u0026quot;),\n Permission.From(\u0026quot;products.create\u0026quot;),\n Permission.From(\u0026quot;products.update\u0026quot;)\n ]\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERole to modify\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPermissions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew permission collection\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-this-is-a-replacement-operation\u0022\u003EImportant: This Is a Replacement Operation\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E replaces the role\u0027s existing permission collection.\u003C/p\u003E\n\u003Cp\u003EIt does not append permissions to the current collection.\u003C/p\u003E\n\u003Cp\u003EFor example, suppose a role currently contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.read\nproducts.create\nproducts.update\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESubmitting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EPermissions =\n[\n Permission.From(\u0026quot;products.read\u0026quot;)\n]\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Emeans the intended new permission collection contains only:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eproducts.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe other permissions are removed from the role.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022safely-add-a-permission-to-an-existing-role\u0022\u003ESafely Add a Permission to an Existing Role\u003C/h3\u003E\n\u003Cp\u003ETo preserve existing permissions, first retrieve the role and construct the complete desired collection.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar query = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = \u0026quot;ProductManager\u0026quot;,\n PageNumber = 1,\n PageSize = 50\n });\n\nif (query.IsSuccess \u0026amp;\u0026amp; query.Value is { } page)\n{\n var role = page.Items.FirstOrDefault(x =\u0026gt; x.Name == \u0026quot;ProductManager\u0026quot;);\n\n if (role is not null)\n {\n var permissions = role.Permissions.Append(Permission.From(\u0026quot;products.delete\u0026quot;)).Distinct().ToArray();\n\n var result =\n await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = role.Id,\n Permissions = permissions\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a read-modify-write pattern, not an atomic append operation.\u003C/p\u003E\n\u003Cp\u003EIf multiple administrators edit the same role concurrently, the last successful replacement may overwrite another administrator\u0027s changes.\u003C/p\u003E\n\u003Cp\u003EApplications requiring concurrency-safe editing should implement suitable coordination or version checking at the application level.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-permission\u0022\u003ERemove a Permission\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar updatedPermissions = role.Permissions.Where(x =\u0026gt; x.Value != \u0026quot;products.delete\u0026quot;).ToArray();\n\nvar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = role.Id,\n Permissions = updatedPermissions\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAgain, submit the complete desired permission set.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-all-permissions\u0022\u003EClear All Permissions\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.SetRolePermissionsAsync(\n new SetRolePermissionsRequest\n {\n RoleId = roleId,\n Permissions = []\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn empty collection requests that the role have no permissions.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the operation is permitted.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-a-role-to-a-user\u0022\u003E15. Assign a Role to a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAssignRoleToUserAsync\u003C/code\u003E to assign an existing role to a user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role assigned.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETarget user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting role name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe role must exist, and the current actor must be authorized to assign it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-multiple-roles\u0022\u003EAssign Multiple Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eforeach (var roleName in new[] { \u0026quot;Manager\u0026quot;, \u0026quot;Auditor\u0026quot; })\n{\n var result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = roleName\n });\n\n if (!result.IsSuccess)\n {\n // Handle the failed assignment.\n break;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach call is a separate operation.\u003C/p\u003E\n\u003Cp\u003EThis example does not provide transactional, all-or-nothing behavior across the entire collection.\u003C/p\u003E\n\u003Cp\u003EFor complex provisioning workflows, consider whether the application needs a server-side orchestration operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-role-from-a-user\u0022\u003E16. Remove a Role from a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERemoveRoleFromUserAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(\n new RemoveRoleRequest\n {\n UserKey = userKey,\n RoleName = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role removed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemoving a role changes the user\u0027s role assignments.\u003C/p\u003E\n\u003Cp\u003EIt does not delete the role itself.\u003C/p\u003E\n\u003Cp\u003EOther users assigned to that role are unaffected by this particular operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-effective-permissions-may-remain\u0022\u003EImportant: Effective Permissions May Remain\u003C/h3\u003E\n\u003Cp\u003EA user may receive the same permission through multiple roles.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Manager\n | \u002B-- products.read\n |\n \u002B-- Auditor\n \u002B-- products.read\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemoving \u003Ccode\u003EManager\u003C/code\u003E does not necessarily remove the user\u0027s ability to read products because \u003Ccode\u003EAuditor\u003C/code\u003E still provides \u003Ccode\u003Eproducts.read\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ETo determine whether the user may perform an action, rely on the server\u0027s authorization decision rather than inspecting one role assignment in isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-role\u0022\u003E17. Delete a Role\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteRoleAsync\u003C/code\u003E to delete a role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request supports a deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe availability and effect of each deletion mode are determined by server-side lifecycle rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022roledeleteresult\u0022\u003ERoleDeleteResult\u003C/h3\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RoleDeleteResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERoleId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeleted role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemovedAssignments\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumber of user-role assignments removed\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EApplied deletion mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeletedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeletion timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-the-result\u0022\u003EInspect the Result\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } deleted)\n{\n Console.WriteLine($\u0026quot;Removed assignments: {deleted.RemovedAssignments}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDeleting a role removes its assignments from users.\u003C/p\u003E\n\u003Cp\u003EThis can affect multiple users at once.\u003C/p\u003E\n\u003Cp\u003EFor that reason, role deletion should be presented as a sensitive administrative operation with appropriate confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-role-management-page\u0022\u003E18. Build a Role Management Page\u003C/h2\u003E\n\u003Cp\u003EThe following Blazor example demonstrates querying roles, creating a role, and deleting a selected role.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Authorization.Contracts\n@using CodeBeam.UltimateAuth.Core.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Role Management\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_search\u0026quot; placeholder=\u0026quot;Search roles\u0026quot; /\u0026gt;\n\n\u0026lt;button @onclick=\u0026quot;LoadRolesAsync\u0026quot;\u0026gt;Search\u0026lt;/button\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading roles...\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var role in _roles)\n {\n \u0026lt;div @key=\u0026quot;role.Id\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@role.Name\u0026lt;/strong\u0026gt;\n \u0026lt;span\u0026gt;@role.Permissions.Count permissions\u0026lt;/span\u0026gt;\n \u0026lt;button @onclick=\u0026quot;() =\u0026gt; DeleteRoleAsync(role.Id)\u0026quot;\u0026gt;Delete\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n\u0026lt;h4\u0026gt;Create Role\u0026lt;/h4\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newRoleName\u0026quot; placeholder=\u0026quot;Role name\u0026quot; /\u0026gt;\n\u0026lt;button @onclick=\u0026quot;CreateRoleAsync\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Create\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private IReadOnlyList\u0026lt;RoleInfo\u0026gt; _roles = [];\n\n private string _search = \u0026quot;\u0026quot;;\n private string _newRoleName = \u0026quot;\u0026quot;;\n private string? _message;\n\n private bool _loading = true;\n private bool _busy;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadRolesAsync();\n }\n\n private async Task LoadRolesAsync()\n {\n _loading = true;\n\n try\n {\n var result = await UAuthClient.Authorization.QueryRolesAsync(\n new RoleQuery\n {\n Search = _search,\n PageNumber = 1,\n PageSize = 50\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _roles = page.Items;\n }\n else\n {\n _message = \u0026quot;Unable to load roles.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The authorization service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task CreateRoleAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_newRoleName))\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = _newRoleName\n });\n\n if (result.IsSuccess)\n {\n _newRoleName = \u0026quot;\u0026quot;;\n await LoadRolesAsync();\n }\n else\n {\n _message = \u0026quot;Unable to create role.\u0026quot;;\n }\n }\n finally\n {\n _busy = false;\n }\n }\n\n private async Task DeleteRoleAsync(RoleId roleId)\n {\n // Add a confirmation dialog before deletion in production.\n\n var result = await UAuthClient.Authorization.DeleteRoleAsync(\n new DeleteRoleRequest\n {\n Id = roleId,\n Mode = DeleteMode.Soft\n });\n\n if (result.IsSuccess)\n {\n await LoadRolesAsync();\n }\n else\n {\n _message = \u0026quot;Unable to delete role.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the SDK integration rather than a complete production administration interface.\u003C/p\u003E\n\u003Cp\u003EA production implementation should include pagination, role assignment controls, permission editing, deletion confirmation, and appropriate loading and error handling for every operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-user-role-assignment-interface\u0022\u003E19. Build a User Role Assignment Interface\u003C/h2\u003E\n\u003Cp\u003EAn administrative user details page can display assigned roles and allow administrators to add or remove them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022load-user-roles\u0022\u003ELoad User Roles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.GetUserRolesAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 50\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } data)\n{\n var assignedRoles = data.Roles.Items.ToList();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assign-a-selected-role\u0022\u003EAssign a Selected Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.AssignRoleToUserAsync(\n new AssignRoleRequest\n {\n UserKey = userKey,\n RoleName = selectedRoleName\n });\n\nif (result.IsSuccess)\n{\n // Reload the user\u0027s assigned roles.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022remove-a-selected-role\u0022\u003ERemove a Selected Role\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(\n new RemoveRoleRequest\n {\n UserKey = userKey,\n RoleName = selectedRoleName\n });\n\nif (result.IsSuccess)\n{\n // Reload the user\u0027s assigned roles.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis pattern works well alongside \u003Ccode\u003EUAuthClient.Users\u003C/code\u003E for building a unified user administration interface.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/user-management\u0022\u003EUser Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-state-events\u0022\u003E20. Authorization State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthAuthorizationClient\u003C/code\u003E publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.AuthorizationChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eafter successful authorization mutations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPublishes \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAssignRoleToUserAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERemoveRoleFromUserAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERenameRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreateRoleAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGetMyRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGetUserRolesAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe event follows the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can be used to refresh navigation menus, role assignment views, or permission-sensitive UI.\u003C/p\u003E\n\u003Cp\u003EHowever, it is a \u003Cstrong\u003Elocal client event\u003C/strong\u003E, not a guarantee that all active clients or distributed caches have immediately observed the change.\u003C/p\u003E\n\u003Cp\u003EFor example, an administrator assigning a role to another user does not automatically mean that user\u0027s open browser receives the administrator\u0027s local event.\u003C/p\u003E\n\u003Cp\u003EApplications requiring cross-client updates should provide an appropriate synchronization mechanism.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-changes-and-active-sessions\u0022\u003E21. Authorization Changes and Active Sessions\u003C/h2\u003E\n\u003Cp\u003ERole and permission changes may affect active users.\u003C/p\u003E\n\u003Cp\u003EHowever, the timing of those changes depends on how authorization data is resolved and cached by the server.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EAn administrator removes a role.\u003C/li\u003E\n\u003Cli\u003EThe server persists the new role assignment state.\u003C/li\u003E\n\u003Cli\u003EA subsequent authorization evaluation uses the effective authorization state available to the server.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EIf the application caches authorization information, changes may not become visible everywhere immediately.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a client event invalidates every active session or refreshes every distributed authorization cache.\u003C/p\u003E\n\u003Cp\u003EFor sensitive operations, authorization should be evaluated against sufficiently current server-side state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022roles-are-not-sessions\u0022\u003ERoles Are Not Sessions\u003C/h3\u003E\n\u003Cp\u003ERemoving a role and revoking a session are different operations.\u003C/p\u003E\n\u003Cp\u003ERemoving a role changes authorization data.\u003C/p\u003E\n\u003Cp\u003ERevoking a session changes whether an authentication session remains valid.\u003C/p\u003E\n\u003Cp\u003EApplications may combine these operations when security policy requires immediate termination of access, but the concepts should remain separate.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/session-management\u0022\u003ESession Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-authorization\u0022\u003E22. Multi-Tenant Authorization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth authorization operations are scoped to the current tenant context.\u003C/p\u003E\n\u003Cp\u003ERole names are unique within the relevant tenant, and role assignments belong to the applicable tenant.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ETenant A\n |\n \u002B-- Manager\n | \u002B-- products.read\n |\n \u002B-- User A\n\nTenant B\n |\n \u002B-- Manager\n | \u002B-- reports.read\n |\n \u002B-- User B\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same role name may represent different permission sets in different tenants.\u003C/p\u003E\n\u003Cp\u003EThe client does not gain cross-tenant access merely by providing another user\u0027s \u003Ccode\u003EUserKey\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe server must enforce tenant isolation and administrative authorization.\u003C/p\u003E\n\u003Cp\u003EWhen implementing multi-tenant administration, do not assume role names or permission configurations are globally shared.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-authorization-errors\u0022\u003E23. Handling Authorization Errors\u003C/h2\u003E\n\u003Cp\u003EAuthorization methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-role-creation-errors\u0022\u003EHandle Role Creation Errors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CreateRoleAsync(\n new CreateRoleRequest\n {\n Name = \u0026quot;Manager\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Role created.\n}\nelse if (result.Http.IsConflict)\n{\n // A conflicting role may already exist.\n}\nelse if (result.Http.IsForbidden)\n{\n // Caller lacks permission.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-authorization-check-failures\u0022\u003EHandle Authorization Check Failures\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(\n new AuthorizationCheckRequest\n {\n Action = \u0026quot;update\u0026quot;,\n Resource = \u0026quot;products\u0026quot;\n });\n\nvar allowed = result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true };\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis fail-closed pattern is appropriate when determining whether the UI should expose a protected operation.\u003C/p\u003E\n\u003Cp\u003EUse the server\u0027s structured error information for diagnostics where appropriate.\u003C/p\u003E\n\u003Cp\u003EDo not expose sensitive policy internals unnecessarily.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations-1\u0022\u003E24. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-authorization-is-authoritative\u0022\u003EServer Authorization Is Authoritative\u003C/h3\u003E\n\u003Cp\u003EClient-side checks improve usability but cannot protect an endpoint by themselves.\u003C/p\u003E\n\u003Cp\u003EEvery protected operation must enforce authorization on the server.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022follow-least-privilege\u0022\u003EFollow Least Privilege\u003C/h3\u003E\n\u003Cp\u003EAssign only the permissions a role actually needs.\u003C/p\u003E\n\u003Cp\u003EAvoid using \u003Ccode\u003E*\u003C/code\u003E or broad prefix wildcards for ordinary users.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003ERole creation, permission replacement, role assignment, and role deletion are privileged operations.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize each mutation independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treat-permission-replacement-carefully\u0022\u003ETreat Permission Replacement Carefully\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ESetRolePermissionsAsync\u003C/code\u003E replaces the entire permission collection.\u003C/p\u003E\n\u003Cp\u003EAlways inspect the existing role configuration before submitting changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-reauthentication-requirements\u0022\u003EHandle Reauthentication Requirements\u003C/h3\u003E\n\u003Cp\u003EAn authorization decision can indicate \u003Ccode\u003ERequiresReauthentication\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EApplications should handle this state explicitly rather than treating it as an ordinary permission denial.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022respect-tenant-boundaries\u0022\u003ERespect Tenant Boundaries\u003C/h3\u003E\n\u003Cp\u003ERoles and assignments must remain within their authorized tenant context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-infer-effective-access-from-one-role\u0022\u003EDo Not Infer Effective Access from One Role\u003C/h3\u003E\n\u003Cp\u003EA user can have multiple roles, overlapping permissions, and additional policy restrictions.\u003C/p\u003E\n\u003Cp\u003EUse the server\u0027s authorization decision when determining whether an action is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-for-cached-authorization-state\u0022\u003EAccount for Cached Authorization State\u003C/h3\u003E\n\u003Cp\u003EPermission changes may not propagate instantly to every active client or cached authorization context.\u003C/p\u003E\n\u003Cp\u003EDesign sensitive operations around server-side enforcement and appropriate cache invalidation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022putting-everything-together\u0022\u003E25. Putting Everything Together\u003C/h2\u003E\n\u003Cp\u003EA typical administrative authorization workflow may look like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ECreate role\n |\n v\nConfigure permissions\n |\n v\nAssign role to user\n |\n v\nUser requests protected action\n |\n v\nServer evaluates authorization\n |\n \u002B-- Allow --\u0026gt; Execute operation\n |\n \u002B-- Deny --\u0026gt; Reject operation\n |\n \u002B-- Reauthentication required\n |\n v\n Authenticate again\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUltimateAuth separates role management from authorization enforcement.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to provide flexible administrative interfaces while keeping access decisions under server control.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E \u2014 Understand application policies and access rules.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/authorization-pipeline\u0022\u003EAuthorization Pipeline Internals\u003C/a\u003E \u2014 Explore how authorization decisions are evaluated.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage user accounts and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage authenticated sessions and devices.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session validity and security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-authorization", @@ -364,6 +364,6 @@ "Level": 0 } ], - "SourceHash": "3304F9F304C1A8D3DFDE25BB8369FD2C1265457FAAB5A975F2662FC94FA1FB6D", + "SourceHash": "D14F973C8368A06A394A9B3DE6F13B191FBD8730DA9E9B990023C548FE4F1518", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json index 1a9590e6..989374dd 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/credentials.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/credentials", "Title": "Credentials", - "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for managing authentication credentials, including password creation, password changes, credential revocation, account recovery, and administrative credential operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Credentials\u003C/code\u003E, applications can implement credential management without manually constructing requests to the underlying endpoints.\u003C/p\u003E\n\u003Cp\u003ECredential management is security-sensitive. UltimateAuth separates authenticated credential changes from recovery workflows and provides configurable session invalidation policies.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EICredentialClient\u003C/code\u003E API, its request and response models, and practical implementation patterns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003E1. Understanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential is authentication material associated with a user.\u003C/p\u003E\n\u003Cp\u003EPasswords are the primary credential type supported by the current credential management request contracts.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth also defines a broader \u003Ccode\u003ECredentialType\u003C/code\u003E enum for additional authentication mechanisms.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialType\n{\n Password = 0,\n OneTimeCode = 10,\n EmailOtp = 11,\n SmsOtp = 12,\n Totp = 30,\n Passkey = 100,\n Certificate = 200,\n ApiKey = 210,\n External = 1000\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enum values do not mean every credential type is currently supported by the credential management API.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAddCredentialRequest\u003C/code\u003E currently accepts a secret but does not expose a credential type selector.\u003C/p\u003E\n\u003Cp\u003EDo not assume that passkeys, API keys, or external authentication credentials can be created through \u003Ccode\u003EAddMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-credential-client\u0022\u003E2. Accessing the Credential Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Credentials\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ECredentials\u003C/code\u003E property exposes \u003Ccode\u003EICredentialClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations fall into three groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EGroup\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EAdd, change, and revoke the current user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERecovery\u003C/td\u003E\n\u003Ctd\u003EBegin and complete a credential reset\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage another user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ERecovery operations are designed to support users who cannot authenticate normally.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-password\u0022\u003E3. Change Your Password\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyAsync\u003C/code\u003E when an authenticated user knows their current password and wants to replace it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password changed successfully.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialChangeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022changecredentialrequest\u0022\u003EChangeCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent password; required for self-service changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew password\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is nullable in the shared request contract because administrative changes do not require the user\u0027s existing password.\u003C/p\u003E\n\u003Cp\u003EFor self-service password changes, provide the current secret.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialchangeresult\u0022\u003ECredentialChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential change succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional operation-level failure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type affected by the change\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to change your password.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } change)\n{\n errorMessage = change.Error ?? \u0026quot;Password change failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been changed.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck both the API-level result and the operation-level result when processing credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-event\u0022\u003EClient State Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EChangeMyAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChangedSelf\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event uses the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can help the application refresh authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the server\u0027s credential or session security checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-password-change-form\u0022\u003E4. Build a Password Change Form\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates an authenticated password change form in Blazor.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Change Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;EditForm Model=\u0026quot;_model\u0026quot; OnValidSubmit=\u0026quot;ChangePasswordAsync\u0026quot;\u0026gt;\n \u0026lt;DataAnnotationsValidator /\u0026gt;\n\n \u0026lt;label\u0026gt;Current password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.CurrentPassword\u0026quot; /\u0026gt;\n\n \u0026lt;label\u0026gt;New password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.NewPassword\u0026quot; /\u0026gt;\n\n \u0026lt;button type=\u0026quot;submit\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Change password\u0026lt;/button\u0026gt;\n\u0026lt;/EditForm\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private readonly PasswordChangeModel _model = new();\n private bool _busy;\n private string? _message;\n\n private async Task ChangePasswordAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = _model.CurrentPassword,\n NewSecret = _model.NewPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp;\n result.Value is { IsSuccess: true })\n {\n _model.CurrentPassword = \u0026quot;\u0026quot;;\n _model.NewPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password changed successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to change password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The credential service is unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n\n private sealed class PasswordChangeModel\n {\n [System.ComponentModel.DataAnnotations.Required]\n public string CurrentPassword { get; set; } = \u0026quot;\u0026quot;;\n\n [System.ComponentModel.DataAnnotations.Required]\n public string NewPassword { get; set; } = \u0026quot;\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the client integration.\u003C/p\u003E\n\u003Cp\u003EA production form should additionally provide password confirmation, appropriate password policy feedback, and safeguards against repeated submissions.\u003C/p\u003E\n\u003Cp\u003ENever log password values or include them in application telemetry.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential\u0022\u003E5. Add a Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to add a credential to the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(\n new AddCredentialRequest\n {\n Secret = password\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine($\u0026quot;Credential ID: {added.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialAddResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022addcredentialrequest\u0022\u003EAddCredentialRequest\u003C/h3\u003E\n\u003Cp\u003EThe current request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record AddCredentialRequest\n{\n public required string Secret { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential type and source selection are not currently exposed by this request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialaddresult\u0022\u003ECredentialAddResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential was added\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential ID, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential type, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdding a credential is subject to the server\u0027s credential policies.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may reject attempts to create a duplicate or otherwise unsupported credential.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-change-versus-password-recovery\u0022\u003E6. Password Change Versus Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between two different workflows.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EWorkflow\u003C/th\u003E\n\u003Cth\u003ETypical situation\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword change\u003C/td\u003E\n\u003Ctd\u003EAuthenticated user knows the current password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword recovery\u003C/td\u003E\n\u003Ctd\u003EUser cannot authenticate using the existing password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E \u2192 \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA password change verifies the existing secret.\u003C/p\u003E\n\u003Cp\u003EPassword recovery uses a separate proof mechanism, such as a reset token or code delivered through a trusted channel.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not use administrative password changes as a replacement for public password recovery.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-password-recovery\u0022\u003E7. Begin Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EBeginResetAsync\u003C/code\u003E to initiate a credential recovery workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } reset)\n{\n Console.WriteLine($\u0026quot;Expires at: {reset.ExpiresAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialBeginResetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginresetcredentialrequest\u0022\u003EBeginResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier associated with the recovery request\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type to recover\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCodeType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReset proof format\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reset-code-types\u0022\u003EReset Code Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum ResetCodeType\n{\n Token = 0,\n Code = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EToken\u003C/code\u003E for a token-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECode\u003C/code\u003E for a code-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EThe delivery mechanism is determined by the application\u0027s configured server-side notification implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialbeginresetresult\u0022\u003ECredentialBeginResetResult\u003C/h3\u003E\n\u003Cp\u003EThe result contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialBeginResetResult\n{\n public DateTimeOffset ExpiresAt { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe reset token or code is not returned in the API response.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThis prevents the recovery endpoint from directly exposing the secret needed to take over an account.\u003C/p\u003E\n\u003Cp\u003EThe proof must be delivered through the configured trusted notification channel.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-password-recovery\u0022\u003E8. Complete Password Recovery\u003C/h2\u003E\n\u003Cp\u003EAfter the user receives a reset token or code, call \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetToken = resetToken,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password recovery completed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialActionResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completeresetcredentialrequest\u0022\u003ECompleteResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier used for the recovery workflow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type being recovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDelivered recovery proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe property is named \u003Ccode\u003EResetToken\u003C/code\u003E even when the selected reset format is a code.\u003C/p\u003E\n\u003Cp\u003EUse the exact contract property name.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialactionresult\u0022\u003ECredentialActionResult\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialActionResult\n{\n public bool IsSuccess { get; init; }\n public string? Error { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to complete password recovery.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n errorMessage = action.Error ?? \u0026quot;Password recovery failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been reset.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event is not the same as \u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E, which is published by \u003Ccode\u003EChangeMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-recovery-using-a-numeric-code\u0022\u003E9. Complete Recovery Using a Numeric Code\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports code-based recovery through \u003Ccode\u003EResetCodeType.Code\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBegin the recovery:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar begin = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Code\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen submit the code received by the user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar complete = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetToken = enteredCode,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same completion API handles token-based and code-based recovery.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for validating the submitted proof against the active recovery state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-forgot-password-form\u0022\u003E10. Build a Forgot Password Form\u003C/h2\u003E\n\u003Cp\u003EA typical forgot-password interface has two stages:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ERequest a recovery token or code.\u003C/li\u003E\n\u003Cli\u003ESubmit the received proof with a new password.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-one-request-recovery\u0022\u003EStep One: Request Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Forgot Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;BeginRecoveryAsync\u0026quot;\u0026gt;Send recovery instructions\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string? _message;\n private bool _busy;\n\n private async Task BeginRecoveryAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_email))\n return;\n\n _busy = true;\n\n try\n {\n var result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\n _message = result.IsSuccess\n ? \u0026quot;If the account is eligible, recovery instructions will be sent.\u0026quot;\n : \u0026quot;Unable to process the recovery request.\u0026quot;;\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe confirmation message intentionally avoids revealing whether an account exists.\u003C/p\u003E\n\u003Cp\u003EApplications should avoid introducing account enumeration through UI messages, timing differences, or diagnostic details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-two-complete-recovery\u0022\u003EStep Two: Complete Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Reset Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_token\u0026quot; placeholder=\u0026quot;Recovery token or code\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newPassword\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;New password\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;CompleteRecoveryAsync\u0026quot;\u0026gt;Reset password\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string _token = \u0026quot;\u0026quot;;\n private string _newPassword = \u0026quot;\u0026quot;;\n\n private string? _message;\n private bool _busy;\n\n private async Task CompleteRecoveryAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetToken = _token,\n NewSecret = _newPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n {\n _token = \u0026quot;\u0026quot;;\n _newPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password reset successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to reset password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese examples show the SDK integration.\u003C/p\u003E\n\u003Cp\u003EA production implementation should also validate user input, protect recovery proofs from accidental disclosure, and avoid logging reset tokens or passwords.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-reset-notifications\u0022\u003E11. Configure Reset Notifications\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E to deliver recovery tokens and codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface ICredentialResetNotifier\n{\n Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe notification includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialResetNotification(\n TenantKey Tenant,\n UserKey UserKey,\n CredentialType CredentialType,\n ResetCodeType CodeType,\n string Token,\n DateTimeOffset ExpiresAt);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application must provide a secure delivery implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-notifier\u0022\u003ERegister the Notifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E is an application-defined implementation.\u003C/p\u003E\n\u003Cp\u003EIt might deliver the proof through email, SMS, or another trusted channel associated with the user\u0027s account.\u003C/p\u003E\n\u003Cp\u003EThe notifier should not expose the proof to an arbitrary address supplied by an unauthenticated caller.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022missing-notifier-configuration\u0022\u003EMissing Notifier Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides a \u003Ccode\u003ENotConfiguredCredentialResetNotifier\u003C/code\u003E fallback.\u003C/p\u003E\n\u003Cp\u003EWhen recovery requires notification but no implementation has been configured, the fallback throws an explanatory exception.\u003C/p\u003E\n\u003Cp\u003EThis prevents a reset flow from silently succeeding without a configured proof delivery mechanism.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever return recovery tokens to anonymous clients as a substitute for configuring the notifier.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-security-behavior\u0022\u003E12. Recovery Security Behavior\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is designed around several security properties.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-enumeration-resistance\u0022\u003EAccount Enumeration Resistance\u003C/h3\u003E\n\u003Cp\u003ERecovery initiation should not reveal whether a submitted identifier belongs to an account.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve this behavior in their own UI and logging policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022proof-expiration\u0022\u003EProof Expiration\u003C/h3\u003E\n\u003Cp\u003ERecovery proofs have a server-controlled lifetime.\u003C/p\u003E\n\u003Cp\u003EThe client does not choose an arbitrary expiration time.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-use-proofs\u0022\u003ESingle-Use Proofs\u003C/h3\u003E\n\u003Cp\u003EA successfully consumed recovery proof must not be reusable.\u003C/p\u003E\n\u003Cp\u003EAfter successful completion, a second attempt using the same proof should be rejected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-and-target-binding\u0022\u003EIdentifier and Target Binding\u003C/h3\u003E\n\u003Cp\u003ERecovery must apply to the correct user and credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative recovery must not permit an identifier belonging to one user to reset another user\u0027s credential.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-revocation\u0022\u003ESession Revocation\u003C/h3\u003E\n\u003Cp\u003ESuccessful recovery may invalidate existing sessions according to the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003EBy default, UltimateAuth revokes all session chains after recovery.\u003C/p\u003E\n\u003Cp\u003EFor further details, see \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-your-credential\u0022\u003E13. Revoke Your Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E to revoke a credential belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeMyAsync(\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\nif (result.IsSuccess)\n{\n // Credential revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeCredentialRequest\n{\n public Guid Id { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the credential record ID, not a user ID or session ID.\u003C/p\u003E\n\u003Cp\u003ECredential revocation is distinct from credential deletion.\u003C/p\u003E\n\u003Cp\u003ERevocation changes the credential\u0027s usability, while deletion uses a separate lifecycle operation available through the administrative API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter successful \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application can use this event to refresh credential-related UI.\u003C/p\u003E\n\u003Cp\u003ECredential revocation may affect authentication eligibility and session continuation depending on server policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-credential-management\u0022\u003E14. Administrative Credential Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative credential operations target a specific user.\u003C/p\u003E\n\u003Cp\u003EThey require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential-1\u0022\u003EAdd a Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddUserAsync(userKey,\n new AddCredentialRequest\n {\n Secret = temporaryPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server validates whether the administrative actor may add a credential for the target user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-a-users-password\u0022\u003EChange a User\u0027s Password\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeUserAsync(userKey,\n new ChangeCredentialRequest\n {\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Administrative password change succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is not required for an authorized administrative credential change.\u003C/p\u003E\n\u003Cp\u003EThis makes administrative credential changes a privileged operation.\u003C/p\u003E\n\u003Cp\u003EDo not expose this capability through ordinary self-service UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-credential\u0022\u003ERevoke a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeUserAsync(userKey,\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-administrative-recovery\u0022\u003EBegin Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetUserAsync(userKey,\n new BeginResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-administrative-recovery\u0022\u003EComplete Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetUserAsync(userKey,\n new CompleteResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetToken = recoveryToken,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative recovery remains a proof-based workflow.\u003C/p\u003E\n\u003Cp\u003EThe server validates the relationship between the target user and the supplied identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-users-credential\u0022\u003EDelete a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a destructive administrative operation.\u003C/p\u003E\n\u003Cp\u003EIts availability and effects depend on server authorization and credential lifecycle policies.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations do not automatically publish the same local credential state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-changes-and-session-invalidation\u0022\u003E15. Credential Changes and Session Invalidation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows applications to configure how existing sessions are handled after credential changes and recovery.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions.CredentialSessionPolicy\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe policy contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after self-service credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after administrative credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after credential recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialsessionbehavior\u0022\u003ECredentialSessionBehavior\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialSessionBehavior\n{\n PreserveAll = 0,\n PreserveCurrent = 10,\n RevokeAll = 20\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke other chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-policy\u0022\u003EConfigure the Policy\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe defaults are intended to balance usability and security.\u003C/p\u003E\n\u003Cp\u003EFor example, an authenticated user changing their password can retain their current device while other devices are invalidated.\u003C/p\u003E\n\u003Cp\u003EAn administrator changing a user\u0027s password typically requires broader session revocation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-and-preservecurrent\u0022\u003ERecovery and PreserveCurrent\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not meaningful for many recovery workflows because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s recovery policy documentation specifies that \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is treated as \u003Ccode\u003ERevokeAll\u003C/code\u003E in this context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-considerations\u0022\u003EAuthentication Mode Considerations\u003C/h3\u003E\n\u003Cp\u003EThe precise effect of session invalidation depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn particular, a locally validated JWT access token may remain usable until expiration if the resource server does not consult current revocation state.\u003C/p\u003E\n\u003Cp\u003ECredential session policies should be understood together with the application\u0027s token validation strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-credential-results\u0022\u003E16. Handling Credential Results\u003C/h2\u003E\n\u003Cp\u003ECredential operations use \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESeveral operations also return a domain-level success flag.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-creation\u0022\u003ECredential Creation\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } add)\n{\n // Credential creation was rejected.\n Console.WriteLine(add.Error);\n}\nelse if (result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n // Recovery did not complete successfully.\n}\nelse\n{\n // Recovery succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting or expired operation state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal credential errors directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-state-events\u0022\u003E17. Credential State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthCredentialClient\u003C/code\u003E publishes the following events after successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following operations do not publish those events in the current client implementation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EAdministrative credential operations\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EApplications should refresh relevant data explicitly where needed.\u003C/p\u003E\n\u003Cp\u003EEvents help synchronize client state but do not themselves establish that a credential or session has been invalidated on the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E18. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022never-expose-stored-secrets\u0022\u003ENever Expose Stored Secrets\u003C/h3\u003E\n\u003Cp\u003ECredential secrets must not be returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EApplications should never log passwords, reset proofs, or other authentication secrets.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-secure-transport\u0022\u003EUse Secure Transport\u003C/h3\u003E\n\u003Cp\u003ECredential operations must be performed over appropriately secured transport.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-recovery-proofs\u0022\u003EProtect Recovery Proofs\u003C/h3\u003E\n\u003Cp\u003ERecovery tokens and codes should be short-lived, single-use, and delivered through trusted channels.\u003C/p\u003E\n\u003Cp\u003EDo not store recovery proofs in persistent client storage unless the application has a carefully justified security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-self-service-and-administrative-operations-separate\u0022\u003EKeep Self-Service and Administrative Operations Separate\u003C/h3\u003E\n\u003Cp\u003ESelf-service changes require the current credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative changes are privileged and require server-side authorization.\u003C/p\u003E\n\u003Cp\u003EDo not substitute one for the other.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-account-recovery-safety\u0022\u003EPreserve Account Recovery Safety\u003C/h3\u003E\n\u003Cp\u003EAvoid revealing account existence through public recovery responses.\u003C/p\u003E\n\u003Cp\u003EApply appropriate rate limiting, proof attempt limits, and abuse controls.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022consider-session-consequences\u0022\u003EConsider Session Consequences\u003C/h3\u003E\n\u003Cp\u003EChanging or resetting a password can affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EApplications should explain the expected consequences to users, especially when other devices will be logged out.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-assume-every-credential-type-is-implemented\u0022\u003EDo Not Assume Every Credential Type Is Implemented\u003C/h3\u003E\n\u003Cp\u003EThe existence of a \u003Ccode\u003ECredentialType\u003C/code\u003E enum member does not establish that its complete lifecycle is supported.\u003C/p\u003E\n\u003Cp\u003EUse the currently implemented credential contracts and documented authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage user identifiers and ownership verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage profiles and user lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validation and revocation behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session invalidation and account-level security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for managing authentication credentials, including password creation, password changes, credential revocation, account recovery, and administrative credential operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Credentials\u003C/code\u003E, applications can implement credential management without manually constructing requests to the underlying endpoints.\u003C/p\u003E\n\u003Cp\u003ECredential management is security-sensitive. UltimateAuth separates authenticated credential changes from recovery workflows and provides configurable session invalidation policies.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the current \u003Ccode\u003EICredentialClient\u003C/code\u003E API, its request and response models, and practical implementation patterns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003E1. Understanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential is authentication material associated with a user.\u003C/p\u003E\n\u003Cp\u003EPasswords are the primary credential type supported by the current credential management request contracts.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth also defines a broader \u003Ccode\u003ECredentialType\u003C/code\u003E enum for additional authentication mechanisms.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialType\n{\n Password = 0,\n OneTimeCode = 10,\n EmailOtp = 11,\n SmsOtp = 12,\n Totp = 30,\n Passkey = 100,\n Certificate = 200,\n ApiKey = 210,\n External = 1000\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enum values do not mean every credential type is currently supported by the credential management API.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAddCredentialRequest\u003C/code\u003E currently accepts a secret but does not expose a credential type selector.\u003C/p\u003E\n\u003Cp\u003EDo not assume that passkeys, API keys, or external authentication credentials can be created through \u003Ccode\u003EAddMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-credential-client\u0022\u003E2. Accessing the Credential Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Credentials\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ECredentials\u003C/code\u003E property exposes \u003Ccode\u003EICredentialClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations fall into three groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EGroup\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EAdd, change, and revoke the current user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERecovery\u003C/td\u003E\n\u003Ctd\u003EBegin and complete a credential reset\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage another user\u0027s credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ERecovery operations are designed to support users who cannot authenticate normally.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-password\u0022\u003E3. Change Your Password\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyAsync\u003C/code\u003E when an authenticated user knows their current password and wants to replace it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password changed successfully.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialChangeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022changecredentialrequest\u0022\u003EChangeCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent password; required for self-service changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENew password\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is nullable in the shared request contract because administrative changes do not require the user\u0027s existing password.\u003C/p\u003E\n\u003Cp\u003EFor self-service password changes, provide the current secret.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialchangeresult\u0022\u003ECredentialChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential change succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional operation-level failure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type affected by the change\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = currentPassword,\n NewSecret = newPassword\n });\n\nif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to change your password.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } change)\n{\n errorMessage = change.Error ?? \u0026quot;Password change failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been changed.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck both the API-level result and the operation-level result when processing credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-event\u0022\u003EClient State Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EChangeMyAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChangedSelf\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event uses the configured client state event handling mode.\u003C/p\u003E\n\u003Cp\u003EIt can help the application refresh authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the server\u0027s credential or session security checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-password-change-form\u0022\u003E4. Build a Password Change Form\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates an authenticated password change form in Blazor.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Change Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;EditForm Model=\u0026quot;_model\u0026quot; OnValidSubmit=\u0026quot;ChangePasswordAsync\u0026quot;\u0026gt;\n \u0026lt;DataAnnotationsValidator /\u0026gt;\n\n \u0026lt;label\u0026gt;Current password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.CurrentPassword\u0026quot; /\u0026gt;\n\n \u0026lt;label\u0026gt;New password\u0026lt;/label\u0026gt;\n \u0026lt;InputText type=\u0026quot;password\u0026quot; @bind-Value=\u0026quot;_model.NewPassword\u0026quot; /\u0026gt;\n\n \u0026lt;button type=\u0026quot;submit\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Change password\u0026lt;/button\u0026gt;\n\u0026lt;/EditForm\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private readonly PasswordChangeModel _model = new();\n private bool _busy;\n private string? _message;\n\n private async Task ChangePasswordAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.ChangeMyAsync(\n new ChangeCredentialRequest\n {\n CurrentSecret = _model.CurrentPassword,\n NewSecret = _model.NewPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp;\n result.Value is { IsSuccess: true })\n {\n _model.CurrentPassword = \u0026quot;\u0026quot;;\n _model.NewPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password changed successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to change password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The credential service is unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n\n private sealed class PasswordChangeModel\n {\n [System.ComponentModel.DataAnnotations.Required]\n public string CurrentPassword { get; set; } = \u0026quot;\u0026quot;;\n\n [System.ComponentModel.DataAnnotations.Required]\n public string NewPassword { get; set; } = \u0026quot;\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the client integration.\u003C/p\u003E\n\u003Cp\u003EA production form should additionally provide password confirmation, appropriate password policy feedback, and safeguards against repeated submissions.\u003C/p\u003E\n\u003Cp\u003ENever log password values or include them in application telemetry.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential\u0022\u003E5. Add a Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to add a credential to the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(\n new AddCredentialRequest\n {\n Secret = password\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine($\u0026quot;Credential ID: {added.Id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialAddResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022addcredentialrequest\u0022\u003EAddCredentialRequest\u003C/h3\u003E\n\u003Cp\u003EThe current request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record AddCredentialRequest\n{\n public required string Secret { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECredential type and source selection are not currently exposed by this request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialaddresult\u0022\u003ECredentialAddResult\u003C/h3\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the credential was added\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EError\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential ID, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreated credential type, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdding a credential is subject to the server\u0027s credential policies.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may reject attempts to create a duplicate or otherwise unsupported credential.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-change-versus-password-recovery\u0022\u003E6. Password Change Versus Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between two different workflows.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EWorkflow\u003C/th\u003E\n\u003Cth\u003ETypical situation\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword change\u003C/td\u003E\n\u003Ctd\u003EAuthenticated user knows the current password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPassword recovery\u003C/td\u003E\n\u003Ctd\u003EUser cannot authenticate using the existing password\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E \u2192 \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA password change verifies the existing secret.\u003C/p\u003E\n\u003Cp\u003EPassword recovery uses a separate proof mechanism, such as a reset token or code delivered through a trusted channel.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not use administrative password changes as a replacement for public password recovery.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-password-recovery\u0022\u003E7. Begin Password Recovery\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EBeginResetAsync\u003C/code\u003E to initiate a credential recovery workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } reset)\n{\n Console.WriteLine($\u0026quot;Expires at: {reset.ExpiresAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialBeginResetResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginresetcredentialrequest\u0022\u003EBeginResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier associated with the recovery request\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type to recover\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCodeType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReset proof format\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reset-code-types\u0022\u003EReset Code Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum ResetCodeType\n{\n Token = 0,\n Code = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EToken\u003C/code\u003E for a token-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECode\u003C/code\u003E for a code-based recovery workflow.\u003C/p\u003E\n\u003Cp\u003EThe delivery mechanism is determined by the application\u0027s configured server-side notification implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialbeginresetresult\u0022\u003ECredentialBeginResetResult\u003C/h3\u003E\n\u003Cp\u003EThe result contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialBeginResetResult\n{\n public DateTimeOffset ExpiresAt { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe reset token or code is not returned in the API response.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThis prevents the recovery endpoint from directly exposing the secret needed to take over an account.\u003C/p\u003E\n\u003Cp\u003EThe proof must be delivered through the configured trusted notification channel.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-password-recovery\u0022\u003E8. Complete Password Recovery\u003C/h2\u003E\n\u003Cp\u003EAfter the user receives a reset token or code, call \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = \u0026quot;john@example.com\u0026quot;,\n CredentialType = CredentialType.Password,\n ResetToken = resetToken,\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Password recovery completed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;CredentialActionResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completeresetcredentialrequest\u0022\u003ECompleteResetCredentialRequest\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier used for the recovery workflow\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential type being recovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDelivered recovery proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewSecret\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement secret\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe property is named \u003Ccode\u003EResetToken\u003C/code\u003E even when the selected reset format is a code.\u003C/p\u003E\n\u003Cp\u003EUse the exact contract property name.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialactionresult\u0022\u003ECredentialActionResult\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialActionResult\n{\n public bool IsSuccess { get; init; }\n public string? Error { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n errorMessage = \u0026quot;Unable to complete password recovery.\u0026quot;;\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n errorMessage = action.Error ?? \u0026quot;Password recovery failed.\u0026quot;;\n}\nelse\n{\n successMessage = \u0026quot;Your password has been reset.\u0026quot;;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003ECompleteResetAsync\u003C/code\u003E operation, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe event is not the same as \u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E, which is published by \u003Ccode\u003EChangeMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-recovery-using-a-numeric-code\u0022\u003E9. Complete Recovery Using a Numeric Code\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports code-based recovery through \u003Ccode\u003EResetCodeType.Code\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBegin the recovery:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar begin = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Code\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen submit the code received by the user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar complete = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = email,\n CredentialType = CredentialType.Password,\n ResetToken = enteredCode,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same completion API handles token-based and code-based recovery.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for validating the submitted proof against the active recovery state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-forgot-password-form\u0022\u003E10. Build a Forgot Password Form\u003C/h2\u003E\n\u003Cp\u003EA typical forgot-password interface has two stages:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ERequest a recovery token or code.\u003C/li\u003E\n\u003Cli\u003ESubmit the received proof with a new password.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-one-request-recovery\u0022\u003EStep One: Request Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Forgot Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;BeginRecoveryAsync\u0026quot;\u0026gt;Send recovery instructions\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string? _message;\n private bool _busy;\n\n private async Task BeginRecoveryAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_email))\n return;\n\n _busy = true;\n\n try\n {\n var result = await UAuthClient.Credentials.BeginResetAsync(\n new BeginResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\n _message = result.IsSuccess\n ? \u0026quot;If the account is eligible, recovery instructions will be sent.\u0026quot;\n : \u0026quot;Unable to process the recovery request.\u0026quot;;\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe confirmation message intentionally avoids revealing whether an account exists.\u003C/p\u003E\n\u003Cp\u003EApplications should avoid introducing account enumeration through UI messages, timing differences, or diagnostic details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-two-complete-recovery\u0022\u003EStep Two: Complete Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Domain\n@using CodeBeam.UltimateAuth.Credentials.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Reset Password\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_token\u0026quot; placeholder=\u0026quot;Recovery token or code\u0026quot; /\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newPassword\u0026quot; type=\u0026quot;password\u0026quot; placeholder=\u0026quot;New password\u0026quot; /\u0026gt;\n\n\u0026lt;button type=\u0026quot;button\u0026quot; disabled=\u0026quot;@_busy\u0026quot; @onclick=\u0026quot;CompleteRecoveryAsync\u0026quot;\u0026gt;Reset password\u0026lt;/button\u0026gt;\n\n@if (_message is not null)\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private string _email = \u0026quot;\u0026quot;;\n private string _token = \u0026quot;\u0026quot;;\n private string _newPassword = \u0026quot;\u0026quot;;\n\n private string? _message;\n private bool _busy;\n\n private async Task CompleteRecoveryAsync()\n {\n if (_busy)\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Credentials.CompleteResetAsync(\n new CompleteResetCredentialRequest\n {\n Identifier = _email,\n CredentialType = CredentialType.Password,\n ResetToken = _token,\n NewSecret = _newPassword\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n {\n _token = \u0026quot;\u0026quot;;\n _newPassword = \u0026quot;\u0026quot;;\n _message = \u0026quot;Password reset successfully.\u0026quot;;\n }\n else\n {\n _message = \u0026quot;Unable to reset password.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;Recovery is temporarily unavailable.\u0026quot;;\n }\n finally\n {\n _busy = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese examples show the SDK integration.\u003C/p\u003E\n\u003Cp\u003EA production implementation should also validate user input, protect recovery proofs from accidental disclosure, and avoid logging reset tokens or passwords.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-reset-notifications\u0022\u003E11. Configure Reset Notifications\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E to deliver recovery tokens and codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface ICredentialResetNotifier\n{\n Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe notification includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record CredentialResetNotification(\n TenantKey Tenant,\n UserKey UserKey,\n CredentialType CredentialType,\n ResetCodeType CodeType,\n string Token,\n DateTimeOffset ExpiresAt);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application must provide a secure delivery implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-notifier\u0022\u003ERegister the Notifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E is an application-defined implementation.\u003C/p\u003E\n\u003Cp\u003EIt might deliver the proof through email, SMS, or another trusted channel associated with the user\u0027s account.\u003C/p\u003E\n\u003Cp\u003EThe notifier should not expose the proof to an arbitrary address supplied by an unauthenticated caller.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022missing-notifier-configuration\u0022\u003EMissing Notifier Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides a \u003Ccode\u003ENotConfiguredCredentialResetNotifier\u003C/code\u003E fallback.\u003C/p\u003E\n\u003Cp\u003EWhen recovery requires notification but no implementation has been configured, the fallback throws an explanatory exception.\u003C/p\u003E\n\u003Cp\u003EThis prevents a reset flow from silently succeeding without a configured proof delivery mechanism.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENever return recovery tokens to anonymous clients as a substitute for configuring the notifier.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-security-behavior\u0022\u003E12. Recovery Security Behavior\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is designed around several security properties.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-enumeration-resistance\u0022\u003EAccount Enumeration Resistance\u003C/h3\u003E\n\u003Cp\u003ERecovery initiation should not reveal whether a submitted identifier belongs to an account.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve this behavior in their own UI and logging policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022proof-expiration\u0022\u003EProof Expiration\u003C/h3\u003E\n\u003Cp\u003ERecovery proofs have a server-controlled lifetime.\u003C/p\u003E\n\u003Cp\u003EThe client does not choose an arbitrary expiration time.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-use-proofs\u0022\u003ESingle-Use Proofs\u003C/h3\u003E\n\u003Cp\u003EA successfully consumed recovery proof must not be reusable.\u003C/p\u003E\n\u003Cp\u003EAfter successful completion, a second attempt using the same proof should be rejected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-and-target-binding\u0022\u003EIdentifier and Target Binding\u003C/h3\u003E\n\u003Cp\u003ERecovery must apply to the correct user and credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative recovery must not permit an identifier belonging to one user to reset another user\u0027s credential.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-revocation\u0022\u003ESession Revocation\u003C/h3\u003E\n\u003Cp\u003ESuccessful recovery may invalidate existing sessions according to the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003EBy default, UltimateAuth revokes all session chains after recovery.\u003C/p\u003E\n\u003Cp\u003EFor further details, see \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-your-credential\u0022\u003E13. Revoke Your Credential\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E to revoke a credential belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeMyAsync(\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\nif (result.IsSuccess)\n{\n // Credential revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeCredentialRequest\n{\n public Guid Id { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the credential record ID, not a user ID or session ID.\u003C/p\u003E\n\u003Cp\u003ECredential revocation is distinct from credential deletion.\u003C/p\u003E\n\u003Cp\u003ERevocation changes the credential\u0027s usability, while deletion uses a separate lifecycle operation available through the administrative API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter successful \u003Ccode\u003ERevokeMyAsync\u003C/code\u003E, the SDK publishes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthStateEvent.CredentialsChanged\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application can use this event to refresh credential-related UI.\u003C/p\u003E\n\u003Cp\u003ECredential revocation may affect authentication eligibility and session continuation depending on server policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-credential-management\u0022\u003E14. Administrative Credential Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative credential operations target a specific user.\u003C/p\u003E\n\u003Cp\u003EThey require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-credential-1\u0022\u003EAdd a Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddUserAsync(userKey,\n new AddCredentialRequest\n {\n Secret = temporaryPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server validates whether the administrative actor may add a credential for the target user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-a-users-password\u0022\u003EChange a User\u0027s Password\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.ChangeUserAsync(userKey,\n new ChangeCredentialRequest\n {\n NewSecret = newPassword\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true })\n{\n // Administrative password change succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECurrentSecret\u003C/code\u003E is not required for an authorized administrative credential change.\u003C/p\u003E\n\u003Cp\u003EThis makes administrative credential changes a privileged operation.\u003C/p\u003E\n\u003Cp\u003EDo not expose this capability through ordinary self-service UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-credential\u0022\u003ERevoke a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.RevokeUserAsync(userKey,\n new RevokeCredentialRequest\n {\n Id = credentialId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022begin-administrative-recovery\u0022\u003EBegin Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.BeginResetUserAsync(userKey,\n new BeginResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetCodeType = ResetCodeType.Token\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-administrative-recovery\u0022\u003EComplete Administrative Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetUserAsync(userKey,\n new CompleteResetCredentialRequest\n {\n Identifier = userEmail,\n CredentialType = CredentialType.Password,\n ResetToken = recoveryToken,\n NewSecret = newPassword\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative recovery remains a proof-based workflow.\u003C/p\u003E\n\u003Cp\u003EThe server validates the relationship between the target user and the supplied identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-users-credential\u0022\u003EDelete a User\u0027s Credential\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.DeleteUserAsync(userKey,\n new DeleteCredentialRequest\n {\n Id = credentialId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a destructive administrative operation.\u003C/p\u003E\n\u003Cp\u003EIts availability and effects depend on server authorization and credential lifecycle policies.\u003C/p\u003E\n\u003Cp\u003EAdministrative operations do not automatically publish the same local credential state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-changes-and-session-invalidation\u0022\u003E15. Credential Changes and Session Invalidation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows applications to configure how existing sessions are handled after credential changes and recovery.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions.CredentialSessionPolicy\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe policy contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after self-service credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after administrative credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESessions after credential recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentialsessionbehavior\u0022\u003ECredentialSessionBehavior\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum CredentialSessionBehavior\n{\n PreserveAll = 0,\n PreserveCurrent = 10,\n RevokeAll = 20\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke other chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-policy\u0022\u003EConfigure the Policy\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe defaults are intended to balance usability and security.\u003C/p\u003E\n\u003Cp\u003EFor example, an authenticated user changing their password can retain their current device while other devices are invalidated.\u003C/p\u003E\n\u003Cp\u003EAn administrator changing a user\u0027s password typically requires broader session revocation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recovery-and-preservecurrent\u0022\u003ERecovery and PreserveCurrent\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not meaningful for many recovery workflows because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s recovery policy documentation specifies that \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is treated as \u003Ccode\u003ERevokeAll\u003C/code\u003E in this context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-considerations\u0022\u003EAuthentication Mode Considerations\u003C/h3\u003E\n\u003Cp\u003EThe precise effect of session invalidation depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn particular, a locally validated JWT access token may remain usable until expiration if the resource server does not consult current revocation state.\u003C/p\u003E\n\u003Cp\u003ECredential session policies should be understood together with the application\u0027s token validation strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-credential-results\u0022\u003E16. Handling Credential Results\u003C/h2\u003E\n\u003Cp\u003ECredential operations use \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESeveral operations also return a domain-level success flag.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-creation\u0022\u003ECredential Creation\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.AddMyAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } add)\n{\n // Credential creation was rejected.\n Console.WriteLine(add.Error);\n}\nelse if (result.Value is { IsSuccess: true } added)\n{\n Console.WriteLine(added.Id);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.CompleteResetAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } action)\n{\n // Recovery did not complete successfully.\n}\nelse\n{\n // Recovery succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting or expired operation state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal credential errors directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-state-events\u0022\u003E17. Credential State Events\u003C/h2\u003E\n\u003Cp\u003EThe current \u003Ccode\u003EUAuthCredentialClient\u003C/code\u003E publishes the following events after successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChangedSelf\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECompleteResetAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialsChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following operations do not publish those events in the current client implementation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBeginResetAsync\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EAdministrative credential operations\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EApplications should refresh relevant data explicitly where needed.\u003C/p\u003E\n\u003Cp\u003EEvents help synchronize client state but do not themselves establish that a credential or session has been invalidated on the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E18. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022never-expose-stored-secrets\u0022\u003ENever Expose Stored Secrets\u003C/h3\u003E\n\u003Cp\u003ECredential secrets must not be returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EApplications should never log passwords, reset proofs, or other authentication secrets.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-secure-transport\u0022\u003EUse Secure Transport\u003C/h3\u003E\n\u003Cp\u003ECredential operations must be performed over appropriately secured transport.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-recovery-proofs\u0022\u003EProtect Recovery Proofs\u003C/h3\u003E\n\u003Cp\u003ERecovery tokens and codes should be short-lived, single-use, and delivered through trusted channels.\u003C/p\u003E\n\u003Cp\u003EDo not store recovery proofs in persistent client storage unless the application has a carefully justified security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-self-service-and-administrative-operations-separate\u0022\u003EKeep Self-Service and Administrative Operations Separate\u003C/h3\u003E\n\u003Cp\u003ESelf-service changes require the current credential.\u003C/p\u003E\n\u003Cp\u003EAdministrative changes are privileged and require server-side authorization.\u003C/p\u003E\n\u003Cp\u003EDo not substitute one for the other.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-account-recovery-safety\u0022\u003EPreserve Account Recovery Safety\u003C/h3\u003E\n\u003Cp\u003EAvoid revealing account existence through public recovery responses.\u003C/p\u003E\n\u003Cp\u003EApply appropriate rate limiting, proof attempt limits, and abuse controls.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022consider-session-consequences\u0022\u003EConsider Session Consequences\u003C/h3\u003E\n\u003Cp\u003EChanging or resetting a password can affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EApplications should explain the expected consequences to users, especially when other devices will be logged out.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022do-not-assume-every-credential-type-is-implemented\u0022\u003EDo Not Assume Every Credential Type Is Implemented\u003C/h3\u003E\n\u003Cp\u003EThe existence of a \u003Ccode\u003ECredentialType\u003C/code\u003E enum member does not establish that its complete lifecycle is supported.\u003C/p\u003E\n\u003Cp\u003EUse the currently implemented credential contracts and documented authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieving-password-requirements\u0022\u003ERetrieving Password Requirements\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients can retrieve the effective password policy configured by the authentication server.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to display the current requirements during registration, user creation, and password changes without hardcoding password rules in the UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Credentials.GetPasswordPolicyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } policy)\n{\n // Display the effective requirements.\n var minimumLength = policy.MinLength;\n var maximumLength = policy.MaxLength;\n var level = policy.Level;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response includes the configured policy level, effective length and character requirements, and indicators for server-side common-password, user-similarity, and password-history checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-side-validation\u0022\u003EClient-Side Validation\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUAuthPasswordClientValidator\u003C/code\u003E to provide immediate feedback before submitting a password:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nvar errors = UAuthPasswordClientValidator.Validate(password, policy);\n\nif (errors.Count == 0)\n{\n // Local character and length requirements are satisfied.\n // The server must still validate the submitted password.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe validator returns \u003Ccode\u003EPasswordPolicyErrorCodes\u003C/code\u003E values for failed local requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EClient-side validation is advisory, not authoritative.\u003C/strong\u003E The server remains responsible for the complete password-policy decision, including common-password detection, user similarity, and password history.\u003C/p\u003E\n\u003Cp\u003EFor the built-in Basic, Standard, and Strict requirements, configuration examples, and password-history behavior, see \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage user identifiers and ownership verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage profiles and user lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/token-security\u0022\u003EToken Security\u003C/a\u003E \u2014 Understand token validation and revocation behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand session invalidation and account-level security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-credentials", @@ -298,12 +298,22 @@ "Text": "Do Not Assume Every Credential Type Is Implemented", "Level": 1 }, + { + "Id": "retrieving-password-requirements", + "Text": "Retrieving Password Requirements", + "Level": 0 + }, + { + "Id": "client-side-validation", + "Text": "Client-Side Validation", + "Level": 1 + }, { "Id": "next-steps", "Text": "Next Steps", "Level": 0 } ], - "SourceHash": "DF1EFE42FAB4410FFCA4565BFB89489FAA283C600459D65A80F5FE5BE0DF1044", + "SourceHash": "1B43969AC7029BFBA504044032854991F7A864A82EEE12176E992D9B9B4CB0FA", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/identifiers.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/identifiers.json index 12fae5b6..2c842970 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/identifiers.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/identifiers.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/identifiers", "Title": "Identifiers", - "Html": "\n\u003Cp\u003EUltimateAuth treats user identifiers as independent, managed entities rather than simple fields stored directly on a user profile.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Identifiers\u003C/code\u003E, applications can manage usernames, email addresses, phone numbers, and custom identifiers, including primary designation, ownership verification, availability checks, and administrative operations.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the public \u003Ccode\u003EIUserIdentifierClient\u003C/code\u003E API with practical examples for account settings, registration forms, and administration interfaces.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-identifiers\u0022\u003E1. Understanding User Identifiers\u003C/h2\u003E\n\u003Cp\u003EAn identifier represents a value associated with a user that may be used for identification, authentication, or communication, depending on the application\u0027s configuration.\u003C/p\u003E\n\u003Cp\u003ESupported identifier types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum UserIdentifierType\n{\n Username = 0,\n Email = 10,\n Phone = 20,\n Custom = 100\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA single user can have multiple identifiers.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Username\n | johndoe [Primary]\n |\n \u002B-- Email\n | john@example.com [Primary, Verified]\n | john@business.com [Verified]\n |\n \u002B-- Phone\n \u002B1 555 0100 [Primary, Verified]\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach identifier has its own value, type, verification state, and primary designation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIdentifiers are not profiles.\u003C/strong\u003E A user profile contains information such as display name and biography, while identifiers represent managed identity-related values.\u003C/p\u003E\n\u003Cp\u003EFor general profile management, see \u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-identifier-client\u0022\u003E2. Accessing the Identifier Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIdentifier operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Identifiers\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property exposes \u003Ccode\u003EIUserIdentifierClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe client supports three main categories of operations:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EManage identifiers belonging to the current user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage identifiers belonging to a specified user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAvailability\u003C/td\u003E\n\u003Ctd\u003ECheck whether an identifier value is valid and available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAll operations are subject to server-side authorization and identifier policies.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-identifiers\u0022\u003E3. Get My Identifiers\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyAsync\u003C/code\u003E to retrieve identifiers belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var identifier in page.Items)\n {\n Console.WriteLine($\u0026quot;{identifier.Type}: {identifier.Value}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;UserIdentifierInfo\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022useridentifierinfo\u0022\u003EUserIdentifierInfo\u003C/h3\u003E\n\u003Cp\u003EEach identifier is represented by \u003Ccode\u003EUserIdentifierInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EGuid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier record ID\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifierType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier type\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizedValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer-normalized value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsPrimary\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether it is primary for its type\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsVerified\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether ownership has been verified\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVerifiedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EVerification timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVersion\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Elong\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEntity version\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EId\u003C/code\u003E identifies the managed identifier record and is used by update, verification, primary designation, and deletion operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022filter-identifiers-in-the-ui\u0022\u003EFilter Identifiers in the UI\u003C/h3\u003E\n\u003Cp\u003EFor example, display verified email addresses from a returned page:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var verifiedEmails = page.Items\n .Where(x =\u0026gt;\n x.Type == UserIdentifierType.Email \u0026amp;\u0026amp;\n x.IsVerified)\n .ToList();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis filters the current page only. Retrieve additional pages when the application needs the complete collection.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its configured pagination policy.\u003C/p\u003E\n\u003Cp\u003EThe returned \u003Ccode\u003EPagedResult\u0026lt;UserIdentifierInfo\u0026gt;\u003C/code\u003E includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EItems\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETotalCount\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EHasNext\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n Console.WriteLine($\u0026quot;Total: {page.TotalCount}\u0026quot;);\n\n if (page.HasNext)\n {\n // Enable the next-page control.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not assume the first page contains every identifier.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-identifier\u0022\u003E5. Add an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to associate a new identifier with the current user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-email-address\u0022\u003EAdd an Email Address\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@business.com\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Identifier added.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-phone-number\u0022\u003EAdd a Phone Number\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Phone,\n Value = \u0026quot;\u002B15550100\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-username\u0022\u003EAdd a Username\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Username,\n Value = \u0026quot;johndoe\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-custom-identifier\u0022\u003EAdd a Custom Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Custom,\n Value = \u0026quot;external-customer-123\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECustom identifier support does not automatically mean that arbitrary custom values can be used for authentication.\u003C/p\u003E\n\u003Cp\u003ELogin eligibility depends on server configuration and the relevant identifier policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-as-primary\u0022\u003EAdd as Primary\u003C/h3\u003E\n\u003Cp\u003ESet \u003Ccode\u003EIsPrimary\u003C/code\u003E when creating the identifier:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@example.com\u0026quot;,\n IsPrimary = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server determines whether the new value can be assigned as primary.\u003C/p\u003E\n\u003Cp\u003EAdding an identifier does not automatically establish verified ownership.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-uniqueness-and-normalization\u0022\u003E6. Identifier Uniqueness and Normalization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth normalizes identifier values and enforces uniqueness within the relevant tenant boundary.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to work with consistent identifier representations.\u003C/p\u003E\n\u003Cp\u003EFor example, email or username normalization may affect how equivalent input values are compared.\u003C/p\u003E\n\u003Cp\u003EThe exact normalization rules depend on the identifier type and server implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-server-results-as-authoritative\u0022\u003EUse Server Results as Authoritative\u003C/h3\u003E\n\u003Cp\u003EDo not implement a separate client-side normalization algorithm and assume it exactly matches the server.\u003C/p\u003E\n\u003Cp\u003EClient-side formatting can improve the user experience, but the server must remain responsible for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EValidating identifier format.\u003C/li\u003E\n\u003Cli\u003ENormalizing identifier values.\u003C/li\u003E\n\u003Cli\u003EEnforcing uniqueness.\u003C/li\u003E\n\u003Cli\u003EChecking tenant ownership.\u003C/li\u003E\n\u003Cli\u003EApplying identifier-specific policies.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn identifier value that appears available in the UI may become unavailable before the user submits it.\u003C/p\u003E\n\u003Cp\u003EAlways handle the result of the actual add or update operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-identifier-availability\u0022\u003E7. Check Identifier Availability\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAvailabilityAsync\u003C/code\u003E to check whether an identifier value is valid and available.\u003C/p\u003E\n\u003Cp\u003EThis is useful for registration forms and account settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-a-username\u0022\u003ECheck a Username\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Username,\n Value = \u0026quot;johndoe\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } availability)\n{\n if (!availability.IsValid)\n {\n // The identifier value is invalid.\n }\n else if (!availability.IsAvailable)\n {\n // The identifier is already unavailable.\n }\n else\n {\n // The identifier is currently available.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-an-email-address\u0022\u003ECheck an Email Address\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022availability-result\u0022\u003EAvailability Result\u003C/h3\u003E\n\u003Cp\u003EThe operation returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserIdentifierAvailabilityResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response value contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsValid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the value passes validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAvailable\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the value is currently available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizedValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalized identifier value, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EErrors\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidation errors\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022display-validation-feedback\u0022\u003EDisplay Validation Feedback\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Username,\n Value = username\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } availability)\n{\n if (!availability.IsValid)\n {\n foreach (var error in availability.Errors)\n {\n // Display the appropriate validation feedback.\n }\n }\n else if (!availability.IsAvailable)\n {\n message = \u0026quot;This username is unavailable.\u0026quot;;\n }\n else\n {\n message = \u0026quot;This username is available.\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAn availability check is advisory, not a reservation.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe value may become unavailable between the availability check and the subsequent creation operation.\u003C/p\u003E\n\u003Cp\u003EFor public registration forms, also consider whether exposing detailed availability information could enable account enumeration. Apply appropriate server-side controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-an-identifier\u0022\u003E8. Update an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateMyAsync\u003C/code\u003E to change the value of an existing identifier.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UpdateMyAsync(\n new UpdateUserIdentifierRequest\n {\n Id = identifierId,\n NewValue = \u0026quot;newemail@example.com\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Identifier updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateUserIdentifierRequest\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting identifier record ID\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement identifier value\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUnlike profile updates, this operation targets one identifier record.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the user\u0027s entire identifier collection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-after-an-update\u0022\u003EVerification After an Update\u003C/h3\u003E\n\u003Cp\u003EChanging an identifier\u0027s value may require ownership verification again.\u003C/p\u003E\n\u003Cp\u003EFor example, verification of an old email address does not prove ownership of a new email address.\u003C/p\u003E\n\u003Cp\u003EApplications should reload the identifier after updating it and inspect its current \u003Ccode\u003EIsVerified\u003C/code\u003E state.\u003C/p\u003E\n\u003Cp\u003EDo not assume the previous verification state remains valid.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-primary-identifiers\u0022\u003E9. Understanding Primary Identifiers\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports \u003Cstrong\u003Eone primary identifier per type\u003C/strong\u003E, rather than one global primary identifier for the entire user.\u003C/p\u003E\n\u003Cp\u003EFor example, a user may have:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EOne primary username.\u003C/li\u003E\n\u003Cli\u003EOne primary email.\u003C/li\u003E\n\u003Cli\u003EOne primary phone number.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese primary designations are independent.\u003C/p\u003E\n\u003Cp\u003EThe primary flag identifies the preferred identifier within its type.\u003C/p\u003E\n\u003Cp\u003EHow the application uses primary identifiers for display, communication, or authentication depends on its configuration and business rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-a-primary-identifier\u0022\u003ESet a Primary Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESetMyPrimaryAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.SetMyPrimaryAsync(\n new SetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\nif (result.IsSuccess)\n{\n // Primary designation updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen a different identifier becomes primary within the same type, the server maintains the per-type primary constraint.\u003C/p\u003E\n\u003Cp\u003ESetting an email address as primary does not unset the primary phone number or username.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unset-a-primary-identifier\u0022\u003EUnset a Primary Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUnsetMyPrimaryAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(\n new UnsetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhether a primary identifier can be unset without selecting a replacement depends on server policy.\u003C/p\u003E\n\u003Cp\u003EApplications should handle rejection when the server requires a primary identifier of that type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022display-primary-identifiers\u0022\u003EDisplay Primary Identifiers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var primaryIdentifiers = page.Items.Where(x =\u0026gt; x.IsPrimary).ToList();\n\n foreach (var identifier in primaryIdentifiers)\n {\n Console.WriteLine($\u0026quot;{identifier.Type}: {identifier.Value}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemember that this example searches only the returned page.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-identifier-ownership\u0022\u003E10. Verify Identifier Ownership\u003C/h2\u003E\n\u003Cp\u003EVerification is a security-sensitive operation.\u003C/p\u003E\n\u003Cp\u003EA user must not be able to mark an email address or phone number as verified merely by knowing or submitting its identifier ID.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth requires ownership proof for self-service verification.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-an-identifier\u0022\u003EVerify an Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EVerifyMyAsync\u003C/code\u003E with a proof obtained through your application\u0027s verification workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.VerifyMyAsync(\n new VerifyUserIdentifierRequest\n {\n Id = identifierId,\n Proof = verificationProof\n });\n\nif (result.IsSuccess)\n{\n // Identifier verification succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EProof\u003C/code\u003E property is required for self-service verification.\u003C/p\u003E\n\u003Cp\u003EFor example, a verification workflow may deliver a code to the email address or phone number being verified.\u003C/p\u003E\n\u003Cp\u003EThe application then submits the received proof through \u003Ccode\u003EVerifyMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe SDK does not itself guarantee delivery of verification messages.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-flow\u0022\u003EVerification Flow\u003C/h3\u003E\n\u003Cp\u003EA typical verification workflow looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser adds identifier\n |\n v\nApplication initiates verification\n |\n v\nVerification proof is delivered\n |\n v\nUser submits proof\n |\n v\nIdentifiers.VerifyMyAsync(...)\n |\n v\nServer validates ownership proof\n |\n \u002B-- Valid --\u0026gt; Identifier verified\n |\n \u002B-- Invalid --\u0026gt; Verification rejected\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mechanism used to issue and deliver the proof is application-specific.\u003C/p\u003E\n\u003Cp\u003EThe server-side verification implementation must validate the proof before updating the identifier\u0027s verified state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-verification-with-iuseridentifierverifier\u0022\u003E11. Server-Side Verification with IUserIdentifierVerifier\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth defines \u003Ccode\u003EIUserIdentifierVerifier\u003C/code\u003E for validating identifier ownership proof.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface IUserIdentifierVerifier\n{\n Task\u0026lt;bool\u0026gt; VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe verification context includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserIdentifierVerificationContext(\n TenantKey Tenant,\n UserKey UserKey,\n Guid IdentifierId,\n UserIdentifierType Type,\n string Value,\n string Proof);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA secure verifier must bind the proof to:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe tenant.\u003C/li\u003E\n\u003Cli\u003EThe user.\u003C/li\u003E\n\u003Cli\u003EThe identifier record.\u003C/li\u003E\n\u003Cli\u003EThe current identifier value.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EIt must also enforce appropriate expiration, attempt limits, and single-use consumption.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-your-verifier\u0022\u003ERegister Your Verifier\u003C/h3\u003E\n\u003Cp\u003EIn the server application, register your implementation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;IUserIdentifierVerifier, ApplicationIdentifierVerifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationIdentifierVerifier\u003C/code\u003E represents your own implementation of the interface.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth includes a \u003Ccode\u003ENotConfiguredUserIdentifierVerifier\u003C/code\u003E fallback that throws an explanatory exception when verification is attempted without a configured implementation.\u003C/p\u003E\n\u003Cp\u003EThis prevents an unconfigured verifier from silently approving identifier ownership.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not implement a verifier that returns \u003Ccode\u003Etrue\u003C/code\u003E unconditionally in production.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-an-identifier\u0022\u003E12. Delete an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMyAsync\u003C/code\u003E to remove an identifier.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteMyAsync(\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess)\n{\n // Identifier deleted.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request includes a deletion mode:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum DeleteMode\n{\n Soft = 0,\n Hard = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteMyAsync(\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDeletion behavior depends on the server\u0027s identifier lifecycle and authorization rules.\u003C/p\u003E\n\u003Cp\u003EAn application should not assume that every caller can select either deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deleting-primary-identifiers\u0022\u003EDeleting Primary Identifiers\u003C/h3\u003E\n\u003Cp\u003EDeleting a primary identifier may be restricted.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may require another identifier of the same type to become primary first.\u003C/p\u003E\n\u003Cp\u003EApplications should inspect the result and provide appropriate feedback.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deleting-login-identifiers\u0022\u003EDeleting Login Identifiers\u003C/h3\u003E\n\u003Cp\u003ERemoving an identifier that is used for authentication may affect future login attempts.\u003C/p\u003E\n\u003Cp\u003EBefore exposing deletion, consider whether the user will retain a supported method of authentication.\u003C/p\u003E\n\u003Cp\u003EIdentifier deletion should not be treated as equivalent to revoking all existing sessions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-an-identifier-management-page\u0022\u003E13. Build an Identifier Management Page\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates a basic Blazor page that lists identifiers, adds an email address, and allows users to change primary designation.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Users.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Identifiers\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading identifiers...\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var item in _identifiers)\n {\n \u0026lt;div @key=\u0026quot;item.Id\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@item.Value\u0026lt;/strong\u0026gt;\n \u0026lt;span\u0026gt;@item.Type\u0026lt;/span\u0026gt;\n\n @if (item.IsVerified)\n {\n \u0026lt;span\u0026gt;Verified\u0026lt;/span\u0026gt;\n }\n else\n {\n \u0026lt;span\u0026gt;Not verified\u0026lt;/span\u0026gt;\n }\n\n @if (item.IsPrimary)\n {\n \u0026lt;span\u0026gt;Primary\u0026lt;/span\u0026gt;\n }\n else\n {\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; SetPrimaryAsync(item.Id)\u0026quot;\u0026gt;Make primary\u0026lt;/button\u0026gt;\n }\n \u0026lt;/div\u0026gt;\n }\n}\n\n\u0026lt;h4\u0026gt;Add Email\u0026lt;/h4\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newEmail\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;AddEmailAsync\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Add email\u0026lt;/button\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private IReadOnlyList\u0026lt;UserIdentifierInfo\u0026gt; _identifiers = [];\n\n private string _newEmail = \u0026quot;\u0026quot;;\n private string? _message;\n\n private bool _loading = true;\n private bool _busy;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n\n try\n {\n var result = await UAuthClient.Identifiers.GetMyAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 50\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _identifiers = page.Items;\n }\n else\n {\n _message = \u0026quot;Unable to load identifiers.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The identifier service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task AddEmailAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_newEmail))\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = _newEmail\n });\n\n if (result.IsSuccess)\n {\n _newEmail = \u0026quot;\u0026quot;;\n await LoadAsync();\n }\n else\n {\n _message = \u0026quot;Unable to add email.\u0026quot;;\n }\n }\n finally\n {\n _busy = false;\n }\n }\n\n private async Task SetPrimaryAsync(Guid id)\n {\n var result = await UAuthClient.Identifiers.SetMyPrimaryAsync(\n new SetPrimaryUserIdentifierRequest\n {\n Id = id\n });\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _message = \u0026quot;Unable to update primary identifier.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core SDK integration.\u003C/p\u003E\n\u003Cp\u003EIt displays the first page of identifiers. A production implementation should add pagination, verification actions, deletion confirmation, appropriate exception handling, and loading states for all mutations.\u003C/p\u003E\n\u003Cp\u003EFor sensitive changes, consider requiring recent authentication or additional verification according to application policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-identifier-management\u0022\u003E14. Administrative Identifier Management\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides administrative methods for managing identifiers belonging to another user.\u003C/p\u003E\n\u003Cp\u003EThese operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-a-users-identifiers\u0022\u003EGet a User\u0027s Identifiers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetUserAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var identifier in page.Items)\n {\n Console.WriteLine(identifier.Value);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-identifier-1\u0022\u003EAdd an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddUserAsync(userKey,\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;employee@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-an-identifier-1\u0022\u003EUpdate an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UpdateUserAsync(userKey,\n new UpdateUserIdentifierRequest\n {\n Id = identifierId,\n NewValue = \u0026quot;updated@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-primary\u0022\u003ESet Primary\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.SetUserPrimaryAsync(userKey,\n new SetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unset-primary\u0022\u003EUnset Primary\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(userKey,\n new UnsetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-an-identifier-as-administrator\u0022\u003EVerify an Identifier as Administrator\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.VerifyUserAsync(userKey,\n new VerifyUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract does not require \u003Ccode\u003EProof\u003C/code\u003E for authorized administrative verification.\u003C/p\u003E\n\u003Cp\u003EHowever, this does not mean any authenticated caller may bypass ownership verification.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize the administrative verification operation and enforce its security requirements.\u003C/p\u003E\n\u003Cp\u003EAdministrative verification should be reserved for trusted workflows with appropriate auditability.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-an-identifier-1\u0022\u003EDelete an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteUserAsync(userKey,\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative operations act on the specified target user.\u003C/p\u003E\n\u003Cp\u003EThey do not automatically publish the same local identifier state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-identifier-operation-results\u0022\u003E15. Handling Identifier Operation Results\u003C/h2\u003E\n\u003Cp\u003EMost identifier mutations return \u003Ccode\u003EUAuthResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ERetrieval and availability methods return \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-success-and-failure\u0022\u003EHandle Success and Failure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(request);\n\nif (result.IsSuccess)\n{\n // Operation succeeded.\n}\nelse if (result.Http.IsConflict)\n{\n // Identifier conflict.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status code depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E also exposes \u003Ccode\u003EProblem\u003C/code\u003E, \u003Ccode\u003EErrorText\u003C/code\u003E, \u003Ccode\u003ECorrelationId\u003C/code\u003E, and \u003Ccode\u003ETraceId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EUse structured errors when appropriate, but avoid displaying sensitive internal information directly to users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-state-events\u0022\u003E16. Identifier State Events\u003C/h2\u003E\n\u003Cp\u003EThe client publishes \u003Ccode\u003EIdentifiersChanged\u003C/code\u003E after successful self-service identifier mutations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdateMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESetMyPrimaryAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUnsetMyPrimaryAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVerifyMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor add and update operations, the event carries the corresponding request payload.\u003C/p\u003E\n\u003Cp\u003EFor primary designation, verification, and deletion, the SDK publishes the event without a request payload.\u003C/p\u003E\n\u003Cp\u003EThe event handling mode follows the configured client state event options.\u003C/p\u003E\n\u003Cp\u003ERead operations and availability checks do not publish \u003Ccode\u003EIdentifiersChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAdministrative mutations also do not automatically publish this local self-service event.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-identifier-data-after-changes\u0022\u003ERefresh Identifier Data After Changes\u003C/h3\u003E\n\u003Cp\u003EA component may respond to an identifier state event by reloading its identifier list.\u003C/p\u003E\n\u003Cp\u003EAlternatively, after a successful mutation, the calling component can explicitly refresh the relevant data.\u003C/p\u003E\n\u003Cp\u003EClient events are synchronization mechanisms, not authoritative security decisions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E17. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-requires-proof\u0022\u003EVerification Requires Proof\u003C/h3\u003E\n\u003Cp\u003ESelf-service verification must establish ownership through a valid proof.\u003C/p\u003E\n\u003Cp\u003EKnowing an identifier ID is not sufficient.\u003C/p\u003E\n\u003Cp\u003EThe server must validate proof binding, expiration, attempt limits, and single-use consumption.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-does-not-mean-verified\u0022\u003EPrimary Does Not Mean Verified\u003C/h3\u003E\n\u003Cp\u003EAn identifier\u0027s primary designation and verification state are separate properties.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that a primary email or phone number has been verified.\u003C/p\u003E\n\u003Cp\u003ECheck \u003Ccode\u003EIsVerified\u003C/code\u003E where verified ownership is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-eligibility-is-separate\u0022\u003ELogin Eligibility Is Separate\u003C/h3\u003E\n\u003Cp\u003EAn identifier may exist on an account without being permitted for login.\u003C/p\u003E\n\u003Cp\u003ELogin eligibility depends on the configured authentication and identifier policies.\u003C/p\u003E\n\u003Cp\u003EDo not assume that every custom identifier is automatically an authentication identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tenant-boundaries-matter\u0022\u003ETenant Boundaries Matter\u003C/h3\u003E\n\u003Cp\u003EIdentifier uniqueness and ownership are enforced within the relevant tenant context.\u003C/p\u003E\n\u003Cp\u003EClient code must not attempt to bypass tenant isolation by modifying request paths or target user identifiers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022availability-checks-are-not-reservations\u0022\u003EAvailability Checks Are Not Reservations\u003C/h3\u003E\n\u003Cp\u003EAvailability can change between requests.\u003C/p\u003E\n\u003Cp\u003EAlways enforce uniqueness during the actual identifier mutation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-verification-is-privileged\u0022\u003EAdministrative Verification Is Privileged\u003C/h3\u003E\n\u003Cp\u003EThe administrative verification API has different proof requirements from self-service verification.\u003C/p\u003E\n\u003Cp\u003EOnly appropriately authorized actors should be able to use it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-changes-may-affect-authentication\u0022\u003EIdentifier Changes May Affect Authentication\u003C/h3\u003E\n\u003Cp\u003EChanging or deleting a login identifier may affect future authentication.\u003C/p\u003E\n\u003Cp\u003EApplications should distinguish identifier management from session revocation and credential management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage user profiles, account status, and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/credentials\u0022\u003ECredentials\u003C/a\u003E \u2014 Manage passwords, credentials, and recovery workflows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Understand login and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Explore the user domain architecture.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand authentication security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth treats user identifiers as independent, managed entities rather than simple fields stored directly on a user profile.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Identifiers\u003C/code\u003E, applications can manage usernames, email addresses, phone numbers, and custom identifiers, including primary designation, ownership verification, availability checks, and administrative operations.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the public \u003Ccode\u003EIUserIdentifierClient\u003C/code\u003E API with practical examples for account settings, registration forms, and administration interfaces.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-identifiers\u0022\u003E1. Understanding User Identifiers\u003C/h2\u003E\n\u003Cp\u003EAn identifier represents a value associated with a user that may be used for identification, authentication, or communication, depending on the application\u0027s configuration.\u003C/p\u003E\n\u003Cp\u003ESupported identifier types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum UserIdentifierType\n{\n Username = 0,\n Email = 10,\n Phone = 20,\n Custom = 100\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA single user can have multiple identifiers.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- Username\n | johndoe [Primary]\n |\n \u002B-- Email\n | john@example.com [Primary, Verified]\n | john@business.com [Verified]\n |\n \u002B-- Phone\n \u002B1 555 0100 [Primary, Verified]\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach identifier has its own value, type, verification state, and primary designation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIdentifiers are not profiles.\u003C/strong\u003E A user profile contains information such as display name and biography, while identifiers represent managed identity-related values.\u003C/p\u003E\n\u003Cp\u003EFor general profile management, see \u003Ca href=\u0022/docs/client-sdk/user-management\u0022\u003EUser Management\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-identifier-client\u0022\u003E2. Accessing the Identifier Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIdentifier operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Identifiers\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property exposes \u003Ccode\u003EIUserIdentifierClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe client supports three main categories of operations:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service\u003C/td\u003E\n\u003Ctd\u003EManage identifiers belonging to the current user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EManage identifiers belonging to a specified user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAvailability\u003C/td\u003E\n\u003Ctd\u003ECheck whether an identifier value is valid and available\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAll operations are subject to server-side authorization and identifier policies.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-my-identifiers\u0022\u003E3. Get My Identifiers\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyAsync\u003C/code\u003E to retrieve identifiers belonging to the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var identifier in page.Items)\n {\n Console.WriteLine($\u0026quot;{identifier.Type}: {identifier.Value}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;UserIdentifierInfo\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022useridentifierinfo\u0022\u003EUserIdentifierInfo\u003C/h3\u003E\n\u003Cp\u003EEach identifier is represented by \u003Ccode\u003EUserIdentifierInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EGuid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier record ID\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifierType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier type\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizedValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer-normalized value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsPrimary\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether it is primary for its type\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsVerified\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether ownership has been verified\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVerifiedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EVerification timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVersion\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Elong\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEntity version\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EId\u003C/code\u003E identifies the managed identifier record and is used by update, verification, primary designation, and deletion operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022filter-identifiers-in-the-ui\u0022\u003EFilter Identifiers in the UI\u003C/h3\u003E\n\u003Cp\u003EFor example, display verified email addresses from a returned page:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var verifiedEmails = page.Items\n .Where(x =\u0026gt;\n x.Type == UserIdentifierType.Email \u0026amp;\u0026amp;\n x.IsVerified)\n .ToList();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis filters the current page only. Retrieve additional pages when the application needs the complete collection.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its configured pagination policy.\u003C/p\u003E\n\u003Cp\u003EThe returned \u003Ccode\u003EPagedResult\u0026lt;UserIdentifierInfo\u0026gt;\u003C/code\u003E includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EItems\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETotalCount\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EHasNext\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n Console.WriteLine($\u0026quot;Total: {page.TotalCount}\u0026quot;);\n\n if (page.HasNext)\n {\n // Enable the next-page control.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not assume the first page contains every identifier.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-identifier\u0022\u003E5. Add an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EAddMyAsync\u003C/code\u003E to associate a new identifier with the current user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-email-address\u0022\u003EAdd an Email Address\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@business.com\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Identifier added.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-phone-number\u0022\u003EAdd a Phone Number\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Phone,\n Value = \u0026quot;\u002B15550100\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-username\u0022\u003EAdd a Username\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Username,\n Value = \u0026quot;johndoe\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-a-custom-identifier\u0022\u003EAdd a Custom Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Custom,\n Value = \u0026quot;external-customer-123\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECustom identifier support does not automatically mean that arbitrary custom values can be used for authentication.\u003C/p\u003E\n\u003Cp\u003ELogin eligibility depends on server configuration and the relevant identifier policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-as-primary\u0022\u003EAdd as Primary\u003C/h3\u003E\n\u003Cp\u003ESet \u003Ccode\u003EIsPrimary\u003C/code\u003E when creating the identifier:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@example.com\u0026quot;,\n IsPrimary = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server determines whether the new value can be assigned as primary.\u003C/p\u003E\n\u003Cp\u003EAdding an identifier does not automatically establish verified ownership.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-limits\u0022\u003EIdentifier Limits\u003C/h3\u003E\n\u003Cp\u003EThe server may restrict how many identifiers a user can maintain.\u003C/p\u003E\n\u003Cp\u003EBy default, UltimateAuth allows up to \u003Cstrong\u003E10 active identifiers per user\u003C/strong\u003E. Applications may configure a different global limit or optional limits for individual identifier types.\u003C/p\u003E\n\u003Cp\u003EThese limits apply to both self-service and administrative identifier operations.\u003C/p\u003E\n\u003Cp\u003EWhen adding an identifier, the server validates the configured limits before accepting the operation.\u003C/p\u003E\n\u003Cp\u003EPossible validation errors include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EError Code\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_total_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe maximum number of active identifiers has been reached\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_type_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe maximum number of active identifiers of this type has been reached\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EApplications should display an appropriate message when an addition is rejected.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ETotal limit reached:\u003C/strong\u003E \u0026quot;You have reached the maximum number of identifiers allowed for your account.\u0026quot;\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EType limit reached:\u003C/strong\u003E \u0026quot;You have reached the maximum number of identifiers allowed for this type.\u0026quot;\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESoft-deleted identifiers do not count toward active limits.\u003C/p\u003E\n\u003Cp\u003EHowever, deleting an identifier does not necessarily make its value available for reuse. Identifier uniqueness and identifier capacity are separate rules.\u003C/p\u003E\n\u003Cp\u003EThe server remains authoritative when enforcing these limits. Client applications should not rely solely on local identifier counts to determine whether an addition will succeed.\u003C/p\u003E\n\u003Cp\u003EFor configuration details, see \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-uniqueness-and-normalization\u0022\u003E6. Identifier Uniqueness and Normalization\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth normalizes identifier values and enforces uniqueness within the relevant tenant boundary.\u003C/p\u003E\n\u003Cp\u003EThis allows applications to work with consistent identifier representations.\u003C/p\u003E\n\u003Cp\u003EFor example, email or username normalization may affect how equivalent input values are compared.\u003C/p\u003E\n\u003Cp\u003EThe exact normalization rules depend on the identifier type and server implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-server-results-as-authoritative\u0022\u003EUse Server Results as Authoritative\u003C/h3\u003E\n\u003Cp\u003EDo not implement a separate client-side normalization algorithm and assume it exactly matches the server.\u003C/p\u003E\n\u003Cp\u003EClient-side formatting can improve the user experience, but the server must remain responsible for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EValidating identifier format.\u003C/li\u003E\n\u003Cli\u003ENormalizing identifier values.\u003C/li\u003E\n\u003Cli\u003EEnforcing uniqueness.\u003C/li\u003E\n\u003Cli\u003EChecking tenant ownership.\u003C/li\u003E\n\u003Cli\u003EApplying identifier-specific policies.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn identifier value that appears available in the UI may become unavailable before the user submits it.\u003C/p\u003E\n\u003Cp\u003EAlways handle the result of the actual add or update operation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-identifier-availability\u0022\u003E7. Check Identifier Availability\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECheckAvailabilityAsync\u003C/code\u003E to check whether an identifier value is valid and available.\u003C/p\u003E\n\u003Cp\u003EThis is useful for registration forms and account settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-a-username\u0022\u003ECheck a Username\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Username,\n Value = \u0026quot;johndoe\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } availability)\n{\n if (!availability.IsValid)\n {\n // The identifier value is invalid.\n }\n else if (!availability.IsAvailable)\n {\n // The identifier is already unavailable.\n }\n else\n {\n // The identifier is currently available.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022check-an-email-address\u0022\u003ECheck an Email Address\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;john@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022availability-result\u0022\u003EAvailability Result\u003C/h3\u003E\n\u003Cp\u003EThe operation returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserIdentifierAvailabilityResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response value contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsValid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the value passes validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsAvailable\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the value is currently available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizedValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalized identifier value, when available\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EErrors\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidation errors\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022display-validation-feedback\u0022\u003EDisplay Validation Feedback\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.CheckAvailabilityAsync(\n new CheckUserIdentifierAvailabilityRequest\n {\n Type = UserIdentifierType.Username,\n Value = username\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } availability)\n{\n if (!availability.IsValid)\n {\n foreach (var error in availability.Errors)\n {\n // Display the appropriate validation feedback.\n }\n }\n else if (!availability.IsAvailable)\n {\n message = \u0026quot;This username is unavailable.\u0026quot;;\n }\n else\n {\n message = \u0026quot;This username is available.\u0026quot;;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAn availability check is advisory, not a reservation.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe value may become unavailable between the availability check and the subsequent creation operation.\u003C/p\u003E\n\u003Cp\u003EFor public registration forms, also consider whether exposing detailed availability information could enable account enumeration. Apply appropriate server-side controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-an-identifier\u0022\u003E8. Update an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateMyAsync\u003C/code\u003E to change the value of an existing identifier.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UpdateMyAsync(\n new UpdateUserIdentifierRequest\n {\n Id = identifierId,\n NewValue = \u0026quot;newemail@example.com\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Identifier updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateUserIdentifierRequest\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting identifier record ID\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENewValue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReplacement identifier value\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUnlike profile updates, this operation targets one identifier record.\u003C/p\u003E\n\u003Cp\u003EIt does not replace the user\u0027s entire identifier collection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-after-an-update\u0022\u003EVerification After an Update\u003C/h3\u003E\n\u003Cp\u003EChanging an identifier\u0027s value may require ownership verification again.\u003C/p\u003E\n\u003Cp\u003EFor example, verification of an old email address does not prove ownership of a new email address.\u003C/p\u003E\n\u003Cp\u003EApplications should reload the identifier after updating it and inspect its current \u003Ccode\u003EIsVerified\u003C/code\u003E state.\u003C/p\u003E\n\u003Cp\u003EDo not assume the previous verification state remains valid.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-primary-identifiers\u0022\u003E9. Understanding Primary Identifiers\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports \u003Cstrong\u003Eone primary identifier per type\u003C/strong\u003E, rather than one global primary identifier for the entire user.\u003C/p\u003E\n\u003Cp\u003EFor example, a user may have:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EOne primary username.\u003C/li\u003E\n\u003Cli\u003EOne primary email.\u003C/li\u003E\n\u003Cli\u003EOne primary phone number.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese primary designations are independent.\u003C/p\u003E\n\u003Cp\u003EThe primary flag identifies the preferred identifier within its type.\u003C/p\u003E\n\u003Cp\u003EHow the application uses primary identifiers for display, communication, or authentication depends on its configuration and business rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-a-primary-identifier\u0022\u003ESet a Primary Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ESetMyPrimaryAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.SetMyPrimaryAsync(\n new SetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\nif (result.IsSuccess)\n{\n // Primary designation updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen a different identifier becomes primary within the same type, the server maintains the per-type primary constraint.\u003C/p\u003E\n\u003Cp\u003ESetting an email address as primary does not unset the primary phone number or username.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unset-a-primary-identifier\u0022\u003EUnset a Primary Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUnsetMyPrimaryAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(\n new UnsetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhether a primary identifier can be unset without selecting a replacement depends on server policy.\u003C/p\u003E\n\u003Cp\u003EApplications should handle rejection when the server requires a primary identifier of that type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022display-primary-identifiers\u0022\u003EDisplay Primary Identifiers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetMyAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var primaryIdentifiers = page.Items.Where(x =\u0026gt; x.IsPrimary).ToList();\n\n foreach (var identifier in primaryIdentifiers)\n {\n Console.WriteLine($\u0026quot;{identifier.Type}: {identifier.Value}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERemember that this example searches only the returned page.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-identifier-ownership\u0022\u003E10. Verify Identifier Ownership\u003C/h2\u003E\n\u003Cp\u003EVerification is a security-sensitive operation.\u003C/p\u003E\n\u003Cp\u003EA user must not be able to mark an email address or phone number as verified merely by knowing or submitting its identifier ID.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth requires ownership proof for self-service verification.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-an-identifier\u0022\u003EVerify an Identifier\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EVerifyMyAsync\u003C/code\u003E with a proof obtained through your application\u0027s verification workflow.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.VerifyMyAsync(\n new VerifyUserIdentifierRequest\n {\n Id = identifierId,\n Proof = verificationProof\n });\n\nif (result.IsSuccess)\n{\n // Identifier verification succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EProof\u003C/code\u003E property is required for self-service verification.\u003C/p\u003E\n\u003Cp\u003EFor example, a verification workflow may deliver a code to the email address or phone number being verified.\u003C/p\u003E\n\u003Cp\u003EThe application then submits the received proof through \u003Ccode\u003EVerifyMyAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe SDK does not itself guarantee delivery of verification messages.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-flow\u0022\u003EVerification Flow\u003C/h3\u003E\n\u003Cp\u003EA typical verification workflow looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser adds identifier\n |\n v\nApplication initiates verification\n |\n v\nVerification proof is delivered\n |\n v\nUser submits proof\n |\n v\nIdentifiers.VerifyMyAsync(...)\n |\n v\nServer validates ownership proof\n |\n \u002B-- Valid --\u0026gt; Identifier verified\n |\n \u002B-- Invalid --\u0026gt; Verification rejected\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mechanism used to issue and deliver the proof is application-specific.\u003C/p\u003E\n\u003Cp\u003EThe server-side verification implementation must validate the proof before updating the identifier\u0027s verified state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-verification-with-iuseridentifierverifier\u0022\u003E11. Server-Side Verification with IUserIdentifierVerifier\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth defines \u003Ccode\u003EIUserIdentifierVerifier\u003C/code\u003E for validating identifier ownership proof.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic interface IUserIdentifierVerifier\n{\n Task\u0026lt;bool\u0026gt; VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe verification context includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserIdentifierVerificationContext(\n TenantKey Tenant,\n UserKey UserKey,\n Guid IdentifierId,\n UserIdentifierType Type,\n string Value,\n string Proof);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA secure verifier must bind the proof to:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe tenant.\u003C/li\u003E\n\u003Cli\u003EThe user.\u003C/li\u003E\n\u003Cli\u003EThe identifier record.\u003C/li\u003E\n\u003Cli\u003EThe current identifier value.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EIt must also enforce appropriate expiration, attempt limits, and single-use consumption.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-your-verifier\u0022\u003ERegister Your Verifier\u003C/h3\u003E\n\u003Cp\u003EIn the server application, register your implementation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;IUserIdentifierVerifier, ApplicationIdentifierVerifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationIdentifierVerifier\u003C/code\u003E represents your own implementation of the interface.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth includes a \u003Ccode\u003ENotConfiguredUserIdentifierVerifier\u003C/code\u003E fallback that throws an explanatory exception when verification is attempted without a configured implementation.\u003C/p\u003E\n\u003Cp\u003EThis prevents an unconfigured verifier from silently approving identifier ownership.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not implement a verifier that returns \u003Ccode\u003Etrue\u003C/code\u003E unconditionally in production.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-an-identifier\u0022\u003E12. Delete an Identifier\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMyAsync\u003C/code\u003E to remove an identifier.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteMyAsync(\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess)\n{\n // Identifier deleted.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request includes a deletion mode:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum DeleteMode\n{\n Soft = 0,\n Hard = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteMyAsync(\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDeletion behavior depends on the server\u0027s identifier lifecycle and authorization rules.\u003C/p\u003E\n\u003Cp\u003EAn application should not assume that every caller can select either deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deleting-primary-identifiers\u0022\u003EDeleting Primary Identifiers\u003C/h3\u003E\n\u003Cp\u003EDeleting a primary identifier may be restricted.\u003C/p\u003E\n\u003Cp\u003EFor example, the server may require another identifier of the same type to become primary first.\u003C/p\u003E\n\u003Cp\u003EApplications should inspect the result and provide appropriate feedback.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deleting-login-identifiers\u0022\u003EDeleting Login Identifiers\u003C/h3\u003E\n\u003Cp\u003ERemoving an identifier that is used for authentication may affect future login attempts.\u003C/p\u003E\n\u003Cp\u003EBefore exposing deletion, consider whether the user will retain a supported method of authentication.\u003C/p\u003E\n\u003Cp\u003EIdentifier deletion should not be treated as equivalent to revoking all existing sessions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-an-identifier-management-page\u0022\u003E13. Build an Identifier Management Page\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates a basic Blazor page that lists identifiers, adds an email address, and allows users to change primary designation.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Users.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Identifiers\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading identifiers...\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var item in _identifiers)\n {\n \u0026lt;div @key=\u0026quot;item.Id\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@item.Value\u0026lt;/strong\u0026gt;\n \u0026lt;span\u0026gt;@item.Type\u0026lt;/span\u0026gt;\n\n @if (item.IsVerified)\n {\n \u0026lt;span\u0026gt;Verified\u0026lt;/span\u0026gt;\n }\n else\n {\n \u0026lt;span\u0026gt;Not verified\u0026lt;/span\u0026gt;\n }\n\n @if (item.IsPrimary)\n {\n \u0026lt;span\u0026gt;Primary\u0026lt;/span\u0026gt;\n }\n else\n {\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; SetPrimaryAsync(item.Id)\u0026quot;\u0026gt;Make primary\u0026lt;/button\u0026gt;\n }\n \u0026lt;/div\u0026gt;\n }\n}\n\n\u0026lt;h4\u0026gt;Add Email\u0026lt;/h4\u0026gt;\n\u0026lt;input @bind=\u0026quot;_newEmail\u0026quot; type=\u0026quot;email\u0026quot; placeholder=\u0026quot;Email address\u0026quot; /\u0026gt;\n\u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;AddEmailAsync\u0026quot; disabled=\u0026quot;@_busy\u0026quot;\u0026gt;Add email\u0026lt;/button\u0026gt;\n\n@if (!string.IsNullOrWhiteSpace(_message))\n{\n \u0026lt;p role=\u0026quot;status\u0026quot;\u0026gt;@_message\u0026lt;/p\u0026gt;\n}\n\n@code {\n private IReadOnlyList\u0026lt;UserIdentifierInfo\u0026gt; _identifiers = [];\n\n private string _newEmail = \u0026quot;\u0026quot;;\n private string? _message;\n\n private bool _loading = true;\n private bool _busy;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n\n try\n {\n var result = await UAuthClient.Identifiers.GetMyAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 50\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _identifiers = page.Items;\n }\n else\n {\n _message = \u0026quot;Unable to load identifiers.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _message = \u0026quot;The identifier service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task AddEmailAsync()\n {\n if (_busy || string.IsNullOrWhiteSpace(_newEmail))\n return;\n\n _busy = true;\n _message = null;\n\n try\n {\n var result = await UAuthClient.Identifiers.AddMyAsync(\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = _newEmail\n });\n\n if (result.IsSuccess)\n {\n _newEmail = \u0026quot;\u0026quot;;\n await LoadAsync();\n }\n else\n {\n _message = \u0026quot;Unable to add email.\u0026quot;;\n }\n }\n finally\n {\n _busy = false;\n }\n }\n\n private async Task SetPrimaryAsync(Guid id)\n {\n var result = await UAuthClient.Identifiers.SetMyPrimaryAsync(\n new SetPrimaryUserIdentifierRequest\n {\n Id = id\n });\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _message = \u0026quot;Unable to update primary identifier.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core SDK integration.\u003C/p\u003E\n\u003Cp\u003EIt displays the first page of identifiers. A production implementation should add pagination, verification actions, deletion confirmation, appropriate exception handling, and loading states for all mutations.\u003C/p\u003E\n\u003Cp\u003EFor sensitive changes, consider requiring recent authentication or additional verification according to application policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-identifier-management\u0022\u003E14. Administrative Identifier Management\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides administrative methods for managing identifiers belonging to another user.\u003C/p\u003E\n\u003Cp\u003EThese operations require appropriate server-side authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-a-users-identifiers\u0022\u003EGet a User\u0027s Identifiers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.GetUserAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var identifier in page.Items)\n {\n Console.WriteLine(identifier.Value);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-an-identifier-1\u0022\u003EAdd an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddUserAsync(userKey,\n new AddUserIdentifierRequest\n {\n Type = UserIdentifierType.Email,\n Value = \u0026quot;employee@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-an-identifier-1\u0022\u003EUpdate an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UpdateUserAsync(userKey,\n new UpdateUserIdentifierRequest\n {\n Id = identifierId,\n NewValue = \u0026quot;updated@example.com\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022set-primary\u0022\u003ESet Primary\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.SetUserPrimaryAsync(userKey,\n new SetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unset-primary\u0022\u003EUnset Primary\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(userKey,\n new UnsetPrimaryUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-an-identifier-as-administrator\u0022\u003EVerify an Identifier as Administrator\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.VerifyUserAsync(userKey,\n new VerifyUserIdentifierRequest\n {\n Id = identifierId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request contract does not require \u003Ccode\u003EProof\u003C/code\u003E for authorized administrative verification.\u003C/p\u003E\n\u003Cp\u003EHowever, this does not mean any authenticated caller may bypass ownership verification.\u003C/p\u003E\n\u003Cp\u003EThe server must authorize the administrative verification operation and enforce its security requirements.\u003C/p\u003E\n\u003Cp\u003EAdministrative verification should be reserved for trusted workflows with appropriate auditability.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-an-identifier-1\u0022\u003EDelete an Identifier\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.DeleteUserAsync(userKey,\n new DeleteUserIdentifierRequest\n {\n Id = identifierId,\n Mode = DeleteMode.Soft\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative operations act on the specified target user.\u003C/p\u003E\n\u003Cp\u003EThey do not automatically publish the same local identifier state events as self-service operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-identifier-operation-results\u0022\u003E15. Handling Identifier Operation Results\u003C/h2\u003E\n\u003Cp\u003EMost identifier mutations return \u003Ccode\u003EUAuthResult\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ERetrieval and availability methods return \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022handle-success-and-failure\u0022\u003EHandle Success and Failure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Identifiers.AddMyAsync(request);\n\nif (result.IsSuccess)\n{\n // Operation succeeded.\n}\nelse if (result.Http.IsConflict)\n{\n // Identifier conflict.\n}\nelse if (result.Http.IsForbidden)\n{\n // Operation not permitted.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actual status code depends on the server\u0027s response.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E also exposes \u003Ccode\u003EProblem\u003C/code\u003E, \u003Ccode\u003EErrorText\u003C/code\u003E, \u003Ccode\u003ECorrelationId\u003C/code\u003E, and \u003Ccode\u003ETraceId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EUse structured errors when appropriate, but avoid displaying sensitive internal information directly to users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-state-events\u0022\u003E16. Identifier State Events\u003C/h2\u003E\n\u003Cp\u003EThe client publishes \u003Ccode\u003EIdentifiersChanged\u003C/code\u003E after successful self-service identifier mutations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdateMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESetMyPrimaryAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUnsetMyPrimaryAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EVerifyMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMyAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiersChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor add and update operations, the event carries the corresponding request payload.\u003C/p\u003E\n\u003Cp\u003EFor primary designation, verification, and deletion, the SDK publishes the event without a request payload.\u003C/p\u003E\n\u003Cp\u003EThe event handling mode follows the configured client state event options.\u003C/p\u003E\n\u003Cp\u003ERead operations and availability checks do not publish \u003Ccode\u003EIdentifiersChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAdministrative mutations also do not automatically publish this local self-service event.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-identifier-data-after-changes\u0022\u003ERefresh Identifier Data After Changes\u003C/h3\u003E\n\u003Cp\u003EA component may respond to an identifier state event by reloading its identifier list.\u003C/p\u003E\n\u003Cp\u003EAlternatively, after a successful mutation, the calling component can explicitly refresh the relevant data.\u003C/p\u003E\n\u003Cp\u003EClient events are synchronization mechanisms, not authoritative security decisions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003E17. Security Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022verification-requires-proof\u0022\u003EVerification Requires Proof\u003C/h3\u003E\n\u003Cp\u003ESelf-service verification must establish ownership through a valid proof.\u003C/p\u003E\n\u003Cp\u003EKnowing an identifier ID is not sufficient.\u003C/p\u003E\n\u003Cp\u003EThe server must validate proof binding, expiration, attempt limits, and single-use consumption.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-does-not-mean-verified\u0022\u003EPrimary Does Not Mean Verified\u003C/h3\u003E\n\u003Cp\u003EAn identifier\u0027s primary designation and verification state are separate properties.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that a primary email or phone number has been verified.\u003C/p\u003E\n\u003Cp\u003ECheck \u003Ccode\u003EIsVerified\u003C/code\u003E where verified ownership is required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-eligibility-is-separate\u0022\u003ELogin Eligibility Is Separate\u003C/h3\u003E\n\u003Cp\u003EAn identifier may exist on an account without being permitted for login.\u003C/p\u003E\n\u003Cp\u003ELogin eligibility depends on the configured authentication and identifier policies.\u003C/p\u003E\n\u003Cp\u003EDo not assume that every custom identifier is automatically an authentication identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022tenant-boundaries-matter\u0022\u003ETenant Boundaries Matter\u003C/h3\u003E\n\u003Cp\u003EIdentifier uniqueness and ownership are enforced within the relevant tenant context.\u003C/p\u003E\n\u003Cp\u003EClient code must not attempt to bypass tenant isolation by modifying request paths or target user identifiers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022availability-checks-are-not-reservations\u0022\u003EAvailability Checks Are Not Reservations\u003C/h3\u003E\n\u003Cp\u003EAvailability can change between requests.\u003C/p\u003E\n\u003Cp\u003EAlways enforce uniqueness during the actual identifier mutation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-verification-is-privileged\u0022\u003EAdministrative Verification Is Privileged\u003C/h3\u003E\n\u003Cp\u003EThe administrative verification API has different proof requirements from self-service verification.\u003C/p\u003E\n\u003Cp\u003EOnly appropriately authorized actors should be able to use it.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-changes-may-affect-authentication\u0022\u003EIdentifier Changes May Affect Authentication\u003C/h3\u003E\n\u003Cp\u003EChanging or deleting a login identifier may affect future authentication.\u003C/p\u003E\n\u003Cp\u003EApplications should distinguish identifier management from session revocation and credential management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/user-management\u0022\u003EUser Management\u003C/a\u003E \u2014 Manage user profiles, account status, and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/credentials\u0022\u003ECredentials\u003C/a\u003E \u2014 Manage passwords, credentials, and recovery workflows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Understand login and authentication flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Explore the user domain architecture.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand authentication security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-user-identifiers", @@ -63,6 +63,11 @@ "Text": "Add as Primary", "Level": 1 }, + { + "Id": "identifier-limits", + "Text": "Identifier Limits", + "Level": 1 + }, { "Id": "identifier-uniqueness-and-normalization", "Text": "6. Identifier Uniqueness and Normalization", @@ -284,6 +289,6 @@ "Level": 0 } ], - "SourceHash": "8E4DEB0C994EC32D3379CC565FF9A18B7A30AAC11FBE6C2668F2CBC0D1DC0226", + "SourceHash": "ADAC74CA7B87824BCE22E4C2EABE20A76E240F2A57AEC96E517FD9498274F0EF", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json index 0026de63..1e95af80 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/session-management", "Title": "Session Management", - "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for inspecting and managing authentication sessions across devices and application instances.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Sessions\u003C/code\u003E, applications can list session chains, inspect authentication history, revoke individual chains, terminate sessions across devices, and perform administrative session management.\u003C/p\u003E\n\u003Cp\u003EThis guide demonstrates the available operations using the current \u003Ccode\u003EISessionClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-the-session-hierarchy\u0022\u003E1. Understanding the Session Hierarchy\u003C/h2\u003E\n\u003Cp\u003EIn session-managed authentication modes, UltimateAuth organizes authentication state into three levels:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ESession Root\n |\n \u002B-- Session Chain A (Browser)\n | |\n | \u002B-- Session A1\n | \u002B-- Session A2\n |\n \u002B-- Session Chain B (Mobile)\n |\n \u002B-- Session B1\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELevel\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERoot\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated authentication continuity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndividual authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese levels provide different boundaries for session management and revocation.\u003C/p\u003E\n\u003Cp\u003EA chain is associated with a device or client context, but it is not cryptographic proof of physical device identity.\u003C/p\u003E\n\u003Cp\u003EThe available hierarchy and enforcement behavior depend on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor architectural details, see \u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-session-client\u0022\u003E2. Accessing the Session Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESession operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Sessions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ESessions\u003C/code\u003E property exposes \u003Ccode\u003EISessionClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface separates operations for the current user from administrative operations targeting another user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-my-devices-and-session-chains\u0022\u003E3. List My Devices and Session Chains\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E to retrieve the current user\u0027s session chains.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine($\u0026quot;{chain.Browser} - {chain.OperatingSystem}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;SessionChainSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains a paginated collection of session chain summaries.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchainsummary\u0022\u003ESessionChainSummary\u003C/h3\u003E\n\u003Cp\u003EEach summary provides information suitable for building a device management interface.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnique chain identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice category\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOperatingSystem\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported operating system\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient platform\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBrowser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported browser\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EChain creation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELastSeenAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast recorded activity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotationCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded rotation count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded touch count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsRevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the chain is revoked\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevocation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActiveSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssociated active session identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsCurrentDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether this is the current device context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EDevice metadata is descriptive information, not a trusted physical device identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identify-the-current-device\u0022\u003EIdentify the Current Device\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsCurrentDevice\u003C/code\u003E to distinguish the current chain in your UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var current = page.Items.FirstOrDefault(x =\u0026gt; x.IsCurrentDevice);\n\n if (current is not null)\n {\n Console.WriteLine($\u0026quot;Current chain: {current.ChainId}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when displaying a device list with a \u003Cstrong\u003ECurrent device\u003C/strong\u003E indicator.\u003C/p\u003E\n\u003Cp\u003EThe property is determined by the server\u0027s current authentication context.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 10,\n SortBy = \u0026quot;CreatedAt\u0026quot;,\n Descending = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPageRequest\u003C/code\u003E supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size; zero uses the server pagination default\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sorting field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its pagination policy, including default and maximum page sizes.\u003C/p\u003E\n\u003Cp\u003ESupported sorting fields depend on the server endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar pageNumber = 1;\n\nvar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = pageNumber,\n PageSize = 10\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n // Render the chain.\n }\n\n if (page.HasNext)\n {\n // Enable the next-page action.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E includes \u003Ccode\u003ETotalCount\u003C/code\u003E, \u003Ccode\u003EPageNumber\u003C/code\u003E, \u003Ccode\u003EPageSize\u003C/code\u003E, and \u003Ccode\u003EHasNext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EYou can use these properties directly when building pagination controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-session-chain\u0022\u003E5. Inspect a Session Chain\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainDetailAsync\u003C/code\u003E to retrieve detailed information about a specific chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESessionChainId chainId = selectedChain.ChainId;\n\nvar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n Console.WriteLine($\u0026quot;State: {detail.State}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {detail.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Last seen: {detail.LastSeenAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;SessionChainDetail\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchaindetail\u0022\u003ESessionChainDetail\u003C/h3\u003E\n\u003Cp\u003EThe detail model includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EChain identity and device metadata.\u003C/li\u003E\n\u003Cli\u003ECreation and last-seen timestamps.\u003C/li\u003E\n\u003Cli\u003EChain state and revocation information.\u003C/li\u003E\n\u003Cli\u003ERotation and touch counters.\u003C/li\u003E\n\u003Cli\u003EActive session identifier.\u003C/li\u003E\n\u003Cli\u003ESession history.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-history\u0022\u003EInspect Session History\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine($\u0026quot;Session: {session.SessionId}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {session.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Expires: {session.ExpiresAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Revoked: {session.IsRevoked}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach session is represented by \u003Ccode\u003ESessionInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record SessionInfo(\n AuthSessionId SessionId,\n DateTimeOffset CreatedAt,\n DateTimeOffset ExpiresAt,\n bool IsRevoked);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to display individual authentication instances within a device-associated chain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-chain-states\u0022\u003E6. Understanding Chain States\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E defines three values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EState\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EActive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoked chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eswitch (chain.State)\n{\n case SessionChainState.Active:\n // Display as active.\n break;\n\n case SessionChainState.Passive:\n // Display as passive.\n break;\n\n case SessionChainState.Revoked:\n // Display as revoked.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA passive chain should not automatically be presented as a revoked chain.\u003C/p\u003E\n\u003Cp\u003EUse the returned state rather than inferring revocation from the absence of an active session.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-one-of-my-devices\u0022\u003E7. Revoke One of My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E to revoke a selected session chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess)\n{\n // Refresh the device list.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERevoking a chain invalidates its associated session continuity according to the server\u0027s revocation model.\u003C/p\u003E\n\u003Cp\u003EIt does not physically disable the device or prevent the user from authenticating again.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detect-revocation-of-the-current-chain\u0022\u003EDetect Revocation of the Current Chain\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERevokeResult\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeResult\n{\n public bool CurrentChain { get; init; }\n public bool RootRevoked { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can inspect these properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n if (revoke.CurrentChain)\n {\n // The current authentication chain was affected.\n }\n\n if (revoke.RootRevoked)\n {\n // The result reports root-level revocation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E state event.\u003C/p\u003E\n\u003Cp\u003EThis helps the application react when its own authentication context has been invalidated.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-other-devices\u0022\u003E8. Revoke All Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyOtherChainsAsync\u003C/code\u003E to revoke other session chains while retaining the current chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync();\n\nif (result.IsSuccess)\n{\n // Other device chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is suitable for a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action in account settings.\u003C/p\u003E\n\u003Cp\u003EThe server determines the current chain and applies the corresponding revocation scope.\u003C/p\u003E\n\u003Cp\u003EApplications should not attempt to identify the current device by comparing browser names, operating systems, or other descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-my-devices\u0022\u003E9. Revoke All My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E to revoke every session chain associated with the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllMyChainsAsync();\n\nif (result.IsSuccess)\n{\n // All applicable chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis includes the current device-associated chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EA typical use case is an account security page offering \u003Cstrong\u003ELog out everywhere\u003C/strong\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ERevoking server-managed sessions prevents their continued use where the authentication mode enforces that state.\u003C/p\u003E\n\u003Cp\u003EHowever, previously issued JWT access tokens may remain valid until expiration if the receiving resource server validates them locally without consulting current revocation state.\u003C/p\u003E\n\u003Cp\u003EDo not assume that chain revocation automatically invalidates every access token in every authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-session-revocation\u0022\u003E10. Logout Versus Session Revocation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes authentication logout operations through \u003Ccode\u003EFlows\u003C/code\u003E and session management operations through \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBoth may affect authentication state, but they serve different application responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003ERecommended API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ENormal logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout a selected device\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyDeviceAsync(request)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout other devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyOtherDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout all devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAllMyDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInspect session chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.GetMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke a selected chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyChainAsync(chainId)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke other chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke all chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022which-api-should-you-use\u0022\u003EWhich API Should You Use?\u003C/h3\u003E\n\u003Cp\u003EFor ordinary authentication actions, prefer \u003Ccode\u003EFlows\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a session management interface that lists and controls chains, use \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe APIs expose related capabilities through different client surfaces.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EFlows\u003C/code\u003E API emphasizes authentication workflows, while \u003Ccode\u003ESessions\u003C/code\u003E exposes the session management model more directly.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-device-through-flows\u0022\u003ELogout a Device Through Flows\u003C/h3\u003E\n\u003Cp\u003EThe device logout method requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyDeviceAsync(\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPass a \u003Ccode\u003ESessionChainId\u003C/code\u003E, not an individual \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor complete logout examples, see \u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-device-management-page\u0022\u003E11. Build a Device Management Page\u003C/h2\u003E\n\u003Cp\u003EA common application requirement is to show all devices associated with the current account and allow the user to revoke them.\u003C/p\u003E\n\u003Cp\u003EThe following example uses the session client to build a simple Blazor device list.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Core.Domain\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Devices\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading devices...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var chain in _chains)\n {\n \u0026lt;div @key=\u0026quot;chain.ChainId\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@(chain.Browser ?? \u0026quot;Unknown browser\u0026quot;)\u0026lt;/strong\u0026gt;\n \u0026lt;p\u0026gt;@(chain.OperatingSystem ?? \u0026quot;Unknown OS\u0026quot;)\u0026lt;/p\u0026gt;\n \u0026lt;p\u0026gt;State: @chain.State\u0026lt;/p\u0026gt;\n\n @if (chain.IsCurrentDevice)\n {\n \u0026lt;span\u0026gt;Current device\u0026lt;/span\u0026gt;\n }\n\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; RevokeAsync(chain.ChainId)\u0026quot;\u0026gt;Revoke\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n@code {\n private IReadOnlyList\u0026lt;SessionChainSummary\u0026gt; _chains = [];\n private bool _loading = true;\n private string? _error;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Sessions.GetMyChainsAsync(new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _chains = page.Items;\n }\n else\n {\n _error = \u0026quot;Unable to load devices.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The session service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task RevokeAsync(SessionChainId chainId)\n {\n var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _error = \u0026quot;Unable to revoke the selected device.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core integration.\u003C/p\u003E\n\u003Cp\u003EA production UI should additionally provide confirmation before revocation, disable repeated submissions while an operation is in progress, and handle the possibility that the current device is being revoked.\u003C/p\u003E\n\u003Cp\u003EThe example displays the first page. Use the pagination API to expose additional chains.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-session-management\u0022\u003E12. Administrative Session Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative operations allow authorized actors to inspect and manage another user\u0027s session state.\u003C/p\u003E\n\u003Cp\u003EThese methods require appropriate server-side permissions.\u003C/p\u003E\n\u003Cp\u003EThe presence of an administrative SDK method does not grant administrative access.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-a-users-devices\u0022\u003EList a User\u0027s Devices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine(chain.ChainId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-users-chain\u0022\u003EInspect a User\u0027s Chain\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserChainDetailAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainDetailAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine(session.SessionId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis provides the same \u003Ccode\u003ESessionChainDetail\u003C/code\u003E model used for self-service chain inspection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-an-individual-session\u0022\u003ERevoke an Individual Session\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeUserSessionAsync\u003C/code\u003E when you need to revoke one authentication instance rather than its entire chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserSessionAsync(userKey, sessionId);\n\nif (result.IsSuccess)\n{\n // The session revocation operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method requires an \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is different from chain revocation.\u003C/p\u003E\n\u003Cp\u003EA session-level operation targets a single authentication instance; a chain-level operation targets the associated session continuity boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-device-chain\u0022\u003ERevoke a User\u0027s Device Chain\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserChainAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n // Inspect revoke.CurrentChain or revoke.RootRevoked if needed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-user-chains\u0022\u003ERevoke All User Chains\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(userKey);\n\nif (result.IsSuccess)\n{\n // All applicable user chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for administrative account security operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-the-users-session-root\u0022\u003ERevoke the User\u0027s Session Root\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also exposes root-level revocation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserRootAsync(userKey);\n\nif (result.IsSuccess)\n{\n // Root-level revocation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERoot revocation is a stronger account-level security operation than revoking one session or one chain.\u003C/p\u003E\n\u003Cp\u003EIt is intended for situations where the user\u0027s broader session authority must be invalidated.\u003C/p\u003E\n\u003Cp\u003EExamples include account compromise response or other security-critical administrative actions.\u003C/p\u003E\n\u003Cp\u003EDo not use root revocation as a routine substitute for ordinary logout.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-revocation-scope\u0022\u003E13. Choosing the Correct Revocation Scope\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides multiple revocation levels because different security events require different responses.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScope\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003Cth\u003EIntended effect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECurrent logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd current authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIndividual session\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserSessionAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke one authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOne chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyChainAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOther chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERetain current chain, revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAll chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all current user\u0027s chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser root\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserRootAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInvalidate account-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse the narrowest scope that satisfies the security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example, removing one unrecognized device from an account should not automatically require revoking the entire user\u0027s session root.\u003C/p\u003E\n\u003Cp\u003EConversely, a confirmed account-wide compromise may justify broader revocation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-session-operation-results\u0022\u003E14. Handling Session Operation Results\u003C/h2\u003E\n\u003Cp\u003ESession client methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA typical result-handling pattern is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n // Use the returned data.\n}\nelse if (result.Http.IsUnauthorized)\n{\n // Authentication is required.\n}\nelse if (result.Http.IsForbidden)\n{\n // The operation is not authorized.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAvailable response information includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EProblem\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EErrorText\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECorrelationId\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETraceId\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe \u003Ccode\u003EHttp\u003C/code\u003E helper exposes status checks such as \u003Ccode\u003EIsBadRequest\u003C/code\u003E, \u003Ccode\u003EIsUnauthorized\u003C/code\u003E, \u003Ccode\u003EIsForbidden\u003C/code\u003E, and \u003Ccode\u003EIsConflict\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-authorization-failures\u0022\u003EAdministrative Authorization Failures\u003C/h3\u003E\n\u003Cp\u003EAn administrative session operation may return \u003Ccode\u003E403 Forbidden\u003C/code\u003E when the actor lacks the required permission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.Http.IsForbidden)\n{\n // The current actor cannot inspect this user\u0027s sessions.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not attempt to work around authorization failures by switching to a different endpoint.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing the operation\u0027s access policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-state-events\u0022\u003E15. Session State Events\u003C/h2\u003E\n\u003Cp\u003EThe session client publishes state events for certain successful revocation operations.\u003C/p\u003E\n\u003Cp\u003EThe current implementation publishes \u003Ccode\u003ESessionRevoked\u003C/code\u003E when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and the result identifies the current chain.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E succeeds.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther session management methods do not necessarily publish the same event.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that every successful administrative revocation produces a local state event.\u003C/p\u003E\n\u003Cp\u003EState events help the client update its authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EThey do not replace authoritative server-side session validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-operational-considerations\u0022\u003E16. Security and Operational Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-metadata-is-descriptive\u0022\u003EDevice Metadata Is Descriptive\u003C/h3\u003E\n\u003Cp\u003EBrowser, operating system, platform, and device type are useful for displaying session information.\u003C/p\u003E\n\u003Cp\u003EThey are not reliable proof of physical device ownership.\u003C/p\u003E\n\u003Cp\u003EA malicious or modified client may be able to influence some reported metadata.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chains-are-not-permanent-device-identities\u0022\u003ESession Chains Are Not Permanent Device Identities\u003C/h3\u003E\n\u003Cp\u003ERevoking a chain invalidates its authentication continuity.\u003C/p\u003E\n\u003Cp\u003EIt does not permanently ban the physical device.\u003C/p\u003E\n\u003Cp\u003EA later successful login may establish new authentication state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revocation-is-server-controlled\u0022\u003ERevocation Is Server-Controlled\u003C/h3\u003E\n\u003Cp\u003EClient-side removal of a device card does not revoke the underlying session.\u003C/p\u003E\n\u003Cp\u003EAlways use the appropriate session or logout operation and inspect its result.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-matter\u0022\u003EAuthentication Modes Matter\u003C/h3\u003E\n\u003Cp\u003EThe Root \u2192 Chain \u2192 Session hierarchy is relevant to server-managed session modes.\u003C/p\u003E\n\u003Cp\u003EDo not assume identical session management semantics in PureJwt or other modes that do not enforce the same hierarchy during ordinary request validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative session revocation can interrupt another user\u0027s authentication.\u003C/p\u003E\n\u003Cp\u003EThese operations should be protected by appropriate permissions and, where required, stronger authentication or administrative confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication state validation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E \u2014 Session roots, chains, lifecycle, and expiration.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Device-associated authentication contexts.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Revocation and session security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for inspecting and managing authentication sessions across devices and application instances.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Sessions\u003C/code\u003E, applications can list session chains, inspect authentication history, revoke individual chains, terminate sessions across devices, and perform administrative session management.\u003C/p\u003E\n\u003Cp\u003EThis guide demonstrates the available operations using the current \u003Ccode\u003EISessionClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-the-session-hierarchy\u0022\u003E1. Understanding the Session Hierarchy\u003C/h2\u003E\n\u003Cp\u003EIn session-managed authentication modes, UltimateAuth organizes authentication state into three levels:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ESession Root\n |\n \u002B-- Session Chain A (Browser)\n | |\n | \u002B-- Session A1\n | \u002B-- Session A2\n |\n \u002B-- Session Chain B (Mobile)\n |\n \u002B-- Session B1\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELevel\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERoot\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated authentication continuity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndividual authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese levels provide different boundaries for session management and revocation.\u003C/p\u003E\n\u003Cp\u003EA chain is associated with a device or client context, but it is not cryptographic proof of physical device identity.\u003C/p\u003E\n\u003Cp\u003EThe available hierarchy and enforcement behavior depend on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor architectural details, see \u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-session-client\u0022\u003E2. Accessing the Session Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESession operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Sessions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ESessions\u003C/code\u003E property exposes \u003Ccode\u003EISessionClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface separates operations for the current user from administrative operations targeting another user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-my-devices-and-session-chains\u0022\u003E3. List My Devices and Session Chains\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E to retrieve the current user\u0027s session chains.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine($\u0026quot;{chain.Browser} - {chain.OperatingSystem}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;SessionChainSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains a paginated collection of session chain summaries.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchainsummary\u0022\u003ESessionChainSummary\u003C/h3\u003E\n\u003Cp\u003EEach summary provides information suitable for building a device management interface.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnique chain identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice category\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOperatingSystem\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported operating system\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient platform\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBrowser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported browser\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EChain creation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELastSeenAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast recorded activity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotationCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded rotation count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded touch count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETotal number of session records in the chain, including historical and revoked sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsRevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the chain is revoked\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevocation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActiveSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssociated active session identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsCurrentDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether this is the current device context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EDevice metadata is descriptive information, not a trusted physical device identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identify-the-current-device\u0022\u003EIdentify the Current Device\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsCurrentDevice\u003C/code\u003E to distinguish the current chain in your UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var current = page.Items.FirstOrDefault(x =\u0026gt; x.IsCurrentDevice);\n\n if (current is not null)\n {\n Console.WriteLine($\u0026quot;Current chain: {current.ChainId}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when displaying a device list with a \u003Cstrong\u003ECurrent device\u003C/strong\u003E indicator.\u003C/p\u003E\n\u003Cp\u003EThe property is determined by the server\u0027s current authentication context.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 10,\n SortBy = \u0026quot;CreatedAt\u0026quot;,\n Descending = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPageRequest\u003C/code\u003E supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size; zero uses the server pagination default\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sorting field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its pagination policy, including default and maximum page sizes.\u003C/p\u003E\n\u003Cp\u003ESupported sorting fields depend on the server endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar pageNumber = 1;\n\nvar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = pageNumber,\n PageSize = 10\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n // Render the chain.\n }\n\n if (page.HasNext)\n {\n // Enable the next-page action.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E includes \u003Ccode\u003ETotalCount\u003C/code\u003E, \u003Ccode\u003EPageNumber\u003C/code\u003E, \u003Ccode\u003EPageSize\u003C/code\u003E, and \u003Ccode\u003EHasNext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EYou can use these properties directly when building pagination controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-session-chain\u0022\u003E5. Inspect a Session Chain\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainDetailAsync\u003C/code\u003E to retrieve detailed information about a specific chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESessionChainId chainId = selectedChain.ChainId;\n\nvar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n Console.WriteLine($\u0026quot;State: {detail.State}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {detail.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Last seen: {detail.LastSeenAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;SessionChainDetail\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchaindetail\u0022\u003ESessionChainDetail\u003C/h3\u003E\n\u003Cp\u003EThe detail model includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EChain identity and device metadata.\u003C/li\u003E\n\u003Cli\u003ECreation and last-seen timestamps.\u003C/li\u003E\n\u003Cli\u003EChain state and revocation information.\u003C/li\u003E\n\u003Cli\u003ERotation and touch counters.\u003C/li\u003E\n\u003Cli\u003EActive session identifier.\u003C/li\u003E\n\u003Cli\u003ESession history.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-history\u0022\u003EInspect Session History\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine($\u0026quot;Session: {session.SessionId}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {session.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Expires: {session.ExpiresAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Revoked: {session.IsRevoked}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach session is represented by \u003Ccode\u003ESessionInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record SessionInfo(\n AuthSessionId SessionId,\n DateTimeOffset CreatedAt,\n DateTimeOffset ExpiresAt,\n bool IsRevoked);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to display individual authentication instances within a device-associated chain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-chain-states\u0022\u003E6. Understanding Chain States\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E defines three values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EState\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EActive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoked chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eswitch (chain.State)\n{\n case SessionChainState.Active:\n // Display as active.\n break;\n\n case SessionChainState.Passive:\n // Display as passive.\n break;\n\n case SessionChainState.Revoked:\n // Display as revoked.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA passive chain should not automatically be presented as a revoked chain.\u003C/p\u003E\n\u003Cp\u003EUse the returned state rather than inferring revocation from the absence of an active session.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-one-of-my-devices\u0022\u003E7. Revoke One of My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E to revoke a selected session chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess)\n{\n // Refresh the device list.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERevoking a chain invalidates its associated session continuity according to the server\u0027s revocation model.\u003C/p\u003E\n\u003Cp\u003EIt does not physically disable the device or prevent the user from authenticating again.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detect-revocation-of-the-current-chain\u0022\u003EDetect Revocation of the Current Chain\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERevokeResult\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeResult\n{\n public bool CurrentChain { get; init; }\n public bool RootRevoked { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can inspect these properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n if (revoke.CurrentChain)\n {\n // The current authentication chain was affected.\n }\n\n if (revoke.RootRevoked)\n {\n // The result reports root-level revocation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E state event.\u003C/p\u003E\n\u003Cp\u003EThis helps the application react when its own authentication context has been invalidated.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-other-devices\u0022\u003E8. Revoke All Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyOtherChainsAsync\u003C/code\u003E to revoke other session chains while retaining the current chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync();\n\nif (result.IsSuccess)\n{\n // Other device chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is suitable for a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action in account settings.\u003C/p\u003E\n\u003Cp\u003EThe server determines the current chain and applies the corresponding revocation scope.\u003C/p\u003E\n\u003Cp\u003EApplications should not attempt to identify the current device by comparing browser names, operating systems, or other descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-my-devices\u0022\u003E9. Revoke All My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E to revoke every session chain associated with the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllMyChainsAsync();\n\nif (result.IsSuccess)\n{\n // All applicable chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis includes the current device-associated chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EA typical use case is an account security page offering \u003Cstrong\u003ELog out everywhere\u003C/strong\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ERevoking server-managed sessions prevents their continued use where the authentication mode enforces that state.\u003C/p\u003E\n\u003Cp\u003EHowever, previously issued JWT access tokens may remain valid until expiration if the receiving resource server validates them locally without consulting current revocation state.\u003C/p\u003E\n\u003Cp\u003EDo not assume that chain revocation automatically invalidates every access token in every authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-session-revocation\u0022\u003E10. Logout Versus Session Revocation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes authentication logout operations through \u003Ccode\u003EFlows\u003C/code\u003E and session management operations through \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBoth may affect authentication state, but they serve different application responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003ERecommended API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ENormal logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout a selected device\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyDeviceAsync(request)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout other devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyOtherDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout all devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAllMyDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInspect session chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.GetMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke a selected chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyChainAsync(chainId)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke other chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke all chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022which-api-should-you-use\u0022\u003EWhich API Should You Use?\u003C/h3\u003E\n\u003Cp\u003EFor ordinary authentication actions, prefer \u003Ccode\u003EFlows\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a session management interface that lists and controls chains, use \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe APIs expose related capabilities through different client surfaces.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EFlows\u003C/code\u003E API emphasizes authentication workflows, while \u003Ccode\u003ESessions\u003C/code\u003E exposes the session management model more directly.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-device-through-flows\u0022\u003ELogout a Device Through Flows\u003C/h3\u003E\n\u003Cp\u003EThe device logout method requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyDeviceAsync(\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPass a \u003Ccode\u003ESessionChainId\u003C/code\u003E, not an individual \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor complete logout examples, see \u003Ca href=\u0022/docs/client-sdk/authentication\u0022\u003EAuthentication\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-device-management-page\u0022\u003E11. Build a Device Management Page\u003C/h2\u003E\n\u003Cp\u003EA common application requirement is to show all devices associated with the current account and allow the user to revoke them.\u003C/p\u003E\n\u003Cp\u003EThe following example uses the session client to build a simple Blazor device list.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Core.Domain\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Devices\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading devices...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var chain in _chains)\n {\n \u0026lt;div @key=\u0026quot;chain.ChainId\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@(chain.Browser ?? \u0026quot;Unknown browser\u0026quot;)\u0026lt;/strong\u0026gt;\n \u0026lt;p\u0026gt;@(chain.OperatingSystem ?? \u0026quot;Unknown OS\u0026quot;)\u0026lt;/p\u0026gt;\n \u0026lt;p\u0026gt;State: @chain.State\u0026lt;/p\u0026gt;\n\n @if (chain.IsCurrentDevice)\n {\n \u0026lt;span\u0026gt;Current device\u0026lt;/span\u0026gt;\n }\n\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; RevokeAsync(chain.ChainId)\u0026quot;\u0026gt;Revoke\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n@code {\n private IReadOnlyList\u0026lt;SessionChainSummary\u0026gt; _chains = [];\n private bool _loading = true;\n private string? _error;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Sessions.GetMyChainsAsync(new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _chains = page.Items;\n }\n else\n {\n _error = \u0026quot;Unable to load devices.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The session service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task RevokeAsync(SessionChainId chainId)\n {\n var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _error = \u0026quot;Unable to revoke the selected device.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core integration.\u003C/p\u003E\n\u003Cp\u003EA production UI should additionally provide confirmation before revocation, disable repeated submissions while an operation is in progress, and handle the possibility that the current device is being revoked.\u003C/p\u003E\n\u003Cp\u003EThe example displays the first page. Use the pagination API to expose additional chains.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-session-management\u0022\u003E12. Administrative Session Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative operations allow authorized actors to inspect and manage another user\u0027s session state.\u003C/p\u003E\n\u003Cp\u003EThese methods require appropriate server-side permissions.\u003C/p\u003E\n\u003Cp\u003EThe presence of an administrative SDK method does not grant administrative access.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-a-users-devices\u0022\u003EList a User\u0027s Devices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine(chain.ChainId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-users-chain\u0022\u003EInspect a User\u0027s Chain\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserChainDetailAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainDetailAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine(session.SessionId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis provides the same \u003Ccode\u003ESessionChainDetail\u003C/code\u003E model used for self-service chain inspection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-an-individual-session\u0022\u003ERevoke an Individual Session\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeUserSessionAsync\u003C/code\u003E when you need to revoke one authentication instance rather than its entire chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserSessionAsync(userKey, sessionId);\n\nif (result.IsSuccess)\n{\n // The session revocation operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method requires an \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is different from chain revocation.\u003C/p\u003E\n\u003Cp\u003EA session-level operation targets a single authentication instance; a chain-level operation targets the associated session continuity boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-device-chain\u0022\u003ERevoke a User\u0027s Device Chain\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserChainAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n // Inspect revoke.CurrentChain or revoke.RootRevoked if needed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-user-chains\u0022\u003ERevoke All User Chains\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(userKey);\n\nif (result.IsSuccess)\n{\n // All applicable user chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for administrative account security operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-the-users-session-root\u0022\u003ERevoke the User\u0027s Session Root\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also exposes root-level revocation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserRootAsync(userKey);\n\nif (result.IsSuccess)\n{\n // Root-level revocation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERoot revocation is a stronger account-level security operation than revoking one session or one chain.\u003C/p\u003E\n\u003Cp\u003EIt is intended for situations where the user\u0027s broader session authority must be invalidated.\u003C/p\u003E\n\u003Cp\u003EExamples include account compromise response or other security-critical administrative actions.\u003C/p\u003E\n\u003Cp\u003EDo not use root revocation as a routine substitute for ordinary logout.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-revocation-scope\u0022\u003E13. Choosing the Correct Revocation Scope\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides multiple revocation levels because different security events require different responses.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScope\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003Cth\u003EIntended effect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECurrent logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd current authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIndividual session\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserSessionAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke one authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOne chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyChainAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOther chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERetain current chain, revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAll chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all current user\u0027s chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser root\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserRootAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInvalidate account-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse the narrowest scope that satisfies the security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example, removing one unrecognized device from an account should not automatically require revoking the entire user\u0027s session root.\u003C/p\u003E\n\u003Cp\u003EConversely, a confirmed account-wide compromise may justify broader revocation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-session-operation-results\u0022\u003E14. Handling Session Operation Results\u003C/h2\u003E\n\u003Cp\u003ESession client methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA typical result-handling pattern is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n // Use the returned data.\n}\nelse if (result.Http.IsUnauthorized)\n{\n // Authentication is required.\n}\nelse if (result.Http.IsForbidden)\n{\n // The operation is not authorized.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAvailable response information includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EProblem\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EErrorText\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECorrelationId\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETraceId\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe \u003Ccode\u003EHttp\u003C/code\u003E helper exposes status checks such as \u003Ccode\u003EIsBadRequest\u003C/code\u003E, \u003Ccode\u003EIsUnauthorized\u003C/code\u003E, \u003Ccode\u003EIsForbidden\u003C/code\u003E, and \u003Ccode\u003EIsConflict\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-authorization-failures\u0022\u003EAdministrative Authorization Failures\u003C/h3\u003E\n\u003Cp\u003EAn administrative session operation may return \u003Ccode\u003E403 Forbidden\u003C/code\u003E when the actor lacks the required permission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.Http.IsForbidden)\n{\n // The current actor cannot inspect this user\u0027s sessions.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not attempt to work around authorization failures by switching to a different endpoint.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing the operation\u0027s access policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-state-events\u0022\u003E15. Session State Events\u003C/h2\u003E\n\u003Cp\u003EThe session client publishes state events for certain successful revocation operations.\u003C/p\u003E\n\u003Cp\u003EThe current implementation publishes \u003Ccode\u003ESessionRevoked\u003C/code\u003E when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and the result identifies the current chain.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E succeeds.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther session management methods do not necessarily publish the same event.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that every successful administrative revocation produces a local state event.\u003C/p\u003E\n\u003Cp\u003EState events help the client update its authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EThey do not replace authoritative server-side session validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-operational-considerations\u0022\u003E16. Security and Operational Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-metadata-is-descriptive\u0022\u003EDevice Metadata Is Descriptive\u003C/h3\u003E\n\u003Cp\u003EBrowser, operating system, platform, and device type are useful for displaying session information.\u003C/p\u003E\n\u003Cp\u003EThey are not reliable proof of physical device ownership.\u003C/p\u003E\n\u003Cp\u003EA malicious or modified client may be able to influence some reported metadata.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chains-are-not-permanent-device-identities\u0022\u003ESession Chains Are Not Permanent Device Identities\u003C/h3\u003E\n\u003Cp\u003ERevoking a chain invalidates its authentication continuity.\u003C/p\u003E\n\u003Cp\u003EIt does not permanently ban the physical device.\u003C/p\u003E\n\u003Cp\u003EA later successful login may establish new authentication state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revocation-is-server-controlled\u0022\u003ERevocation Is Server-Controlled\u003C/h3\u003E\n\u003Cp\u003EClient-side removal of a device card does not revoke the underlying session.\u003C/p\u003E\n\u003Cp\u003EAlways use the appropriate session or logout operation and inspect its result.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-matter\u0022\u003EAuthentication Modes Matter\u003C/h3\u003E\n\u003Cp\u003EThe Root \u2192 Chain \u2192 Session hierarchy is relevant to server-managed session modes.\u003C/p\u003E\n\u003Cp\u003EDo not assume identical session management semantics in PureJwt or other modes that do not enforce the same hierarchy during ordinary request validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative session revocation can interrupt another user\u0027s authentication.\u003C/p\u003E\n\u003Cp\u003EThese operations should be protected by appropriate permissions and, where required, stronger authentication or administrative confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication state validation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E \u2014 Session roots, chains, lifecycle, and expiration.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Device-associated authentication contexts.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Revocation and session security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-the-session-hierarchy", @@ -194,6 +194,6 @@ "Level": 0 } ], - "SourceHash": "FF79BEDC480EB6D1EB699C30604B98F72D86F0BE4187AF401B25DE17FBDE5252", + "SourceHash": "BB7D06D3A3986BDE3FA6843804B51649FE7B3DE752C889A120F8AE68F3634308", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/user-management.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/user-management.json index 89a7e8ca..ffce1857 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/user-management.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/user-management.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/user-management", "Title": "User Management", - "Html": "\n\u003Cp\u003EThe UltimateAuth Client SDK provides a comprehensive API for managing users, profiles, account status, and user lifecycle operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Users\u003C/code\u003E, applications can implement account settings, profile management, user registration, and administrative user management without manually constructing API requests.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between \u003Cstrong\u003Euser identity\u003C/strong\u003E, \u003Cstrong\u003Euser profiles\u003C/strong\u003E, and \u003Cstrong\u003Eaccount lifecycle state\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EA user has a stable identity and may have multiple profiles. Account status controls lifecycle behavior independently of profile data.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the public \u003Ccode\u003EIUserClient\u003C/code\u003E API, including self-service and administrative operations, structured results, pagination, multi-profile support, and important update semantics.\u003C/p\u003E\n\u003Cp\u003EFor email addresses, usernames, phone numbers, and identifier verification, see \u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-user-client\u0022\u003E1. Accessing the User Client\u003C/h2\u003E\n\u003Cp\u003EIn a Blazor component, inject \u003Ccode\u003EIUAuthClient\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUser operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Users\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUsers\u003C/code\u003E property exposes \u003Ccode\u003EIUserClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations are divided into three functional areas:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ECapabilities\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser lifecycle\u003C/td\u003E\n\u003Ctd\u003ECreate users, change status, delete accounts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service profiles\u003C/td\u003E\n\u003Ctd\u003ERetrieve, update, create, and delete your own profiles\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EQuery users and manage another user\u0027s account and profiles\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ESelf-service methods operate on the authenticated user.\u003C/p\u003E\n\u003Cp\u003EAdministrative methods operate on an explicitly identified target and require the appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-the-current-user\u0022\u003E2. Get the Current User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMeAsync\u003C/code\u003E to retrieve the current user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.GetMeAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } user)\n{\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n Console.WriteLine(user.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserView\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen no profile is specified, UltimateAuth returns the default profile.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userview\u0022\u003EUserView\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUserView\u003C/code\u003E contains account information and the selected profile\u0027s data.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStable user identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent account status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryEmail\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary email address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryPhone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary phone number\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected profile\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFirstName\u003C/code\u003E, \u003Ccode\u003ELastName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProfile name fields\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDisplayName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDisplay name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBio\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBiography\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBirthDate\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBirth date\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGender\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProfile gender field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELanguage\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreferred language\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETimeZone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreferred time zone\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECulture\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECulture preference\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmailVerified\u003C/code\u003E, \u003Ccode\u003EPhoneVerified\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier verification information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount creation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustom profile metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned profile is a view of server-managed user information.\u003C/p\u003E\n\u003Cp\u003EIt should not be treated as a substitute for authoritative authentication or authorization state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-the-current-users-profile\u0022\u003E3. Update the Current User\u0027s Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E to update the authenticated user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;John Doe\u0026quot;,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;,\n Language = \u0026quot;en\u0026quot;,\n TimeZone = \u0026quot;Europe/London\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile update succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-profile-updates-replace-editable-fields\u0022\u003EImportant: Profile Updates Replace Editable Fields\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E uses replacement semantics rather than partial-update semantics.\u003C/p\u003E\n\u003Cp\u003EThis means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EEditable fields supplied in the request replace their previous values.\u003C/li\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003Enull\u003C/code\u003E values clear existing values.\u003C/li\u003E\n\u003Cli\u003EOmitted editable properties are also treated as \u003Ccode\u003Enull\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;John Doe\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis does \u003Cstrong\u003Enot\u003C/strong\u003E mean \u201Cchange only the display name.\u201D\u003C/p\u003E\n\u003Cp\u003EOther editable profile fields may be cleared.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended-read-modify-and-submit\u0022\u003ERecommended: Read, Modify, and Submit\u003C/h3\u003E\n\u003Cp\u003EWhen building a profile editor, retrieve the current profile and preserve values that should remain unchanged.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar currentResult = await UAuthClient.Users.GetMeAsync();\n\nif (!currentResult.IsSuccess || currentResult.Value is not { } current)\n{\n return;\n}\n\nvar update = new UpdateProfileRequest\n{\n FirstName = current.FirstName,\n LastName = current.LastName,\n DisplayName = \u0026quot;John Doe\u0026quot;,\n BirthDate = current.BirthDate,\n Gender = current.Gender,\n Bio = current.Bio,\n Language = current.Language,\n TimeZone = current.TimeZone,\n Culture = current.Culture,\n Metadata = current.Metadata\n};\n\nvar result = await UAuthClient.Users.UpdateMyProfileAsync(update);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis preserves the values returned by the initial read, but it is not an atomic patch operation.\u003C/p\u003E\n\u003Cp\u003EIf another request modifies the profile between the read and update, the later replacement may overwrite those changes.\u003C/p\u003E\n\u003Cp\u003EApplications with concurrent profile editing requirements should account for this behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-state-events\u0022\u003EProfile State Events\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E operation, the client publishes a \u003Ccode\u003EProfileChanged\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EThe event carries the \u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E used for the operation.\u003C/p\u003E\n\u003Cp\u003EThe configured client state event handling mode determines how the application processes the notification.\u003C/p\u003E\n\u003Cp\u003EThe event is published only when the SDK operation reports success.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-multiple-profiles\u0022\u003E4. Understanding Multiple Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports multiple profiles for a single user when enabled through server configuration.\u003C/p\u003E\n\u003Cp\u003EFor example, one account may have:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- default\n |\n \u002B-- business\n |\n \u002B-- public\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAll profiles belong to the same user identity.\u003C/p\u003E\n\u003Cp\u003EThey do not represent separate authentication accounts or independently authenticated principals.\u003C/p\u003E\n\u003Cp\u003EProfiles allow applications to organize different sets of user-facing information under one account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-profile\u0022\u003EDefault Profile\u003C/h3\u003E\n\u003Cp\u003EEvery newly created user receives a default profile.\u003C/p\u003E\n\u003Cp\u003EIts key is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.Default\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EWhen a profile request omits \u003Ccode\u003EProfileKey\u003C/code\u003E, the default profile is selected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-keys\u0022\u003EProfile Keys\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E is a strongly typed value.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryCreate\u003C/code\u003E to construct a custom key:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n throw new InvalidOperationException(\u0026quot;Invalid profile key.\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EProfile keys are normalized by trimming whitespace and converting the value to lowercase.\u003C/p\u003E\n\u003Cp\u003EA valid key must not be empty or exceed 64 characters.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-a-specific-profile\u0022\u003E5. Retrieve a Specific Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetProfileRequest\u003C/code\u003E to select a profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n return;\n}\n\nvar result = await UAuthClient.Users.GetMeAsync(\n new GetProfileRequest\n {\n ProfileKey = profileKey\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } profile)\n{\n Console.WriteLine(profile.DisplayName);\n Console.WriteLine(profile.ProfileKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response is still \u003Ccode\u003EUserView\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe account identity and status remain associated with the same user; the selected profile determines which profile information is returned.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-an-additional-profile\u0022\u003E6. Create an Additional Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateMyProfileAsync\u003C/code\u003E to create a new profile for the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n return;\n}\n\nvar result = await UAuthClient.Users.CreateMyProfileAsync(\n new CreateProfileRequest\n {\n ProfileKey = profileKey,\n DisplayName = \u0026quot;John Doe Consulting\u0026quot;,\n Bio = \u0026quot;Business profile\u0026quot;,\n Language = \u0026quot;en\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile created.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECreateProfileRequest\u003C/code\u003E requires \u003Ccode\u003EProfileKey\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also supports the same general profile information used for user-facing profiles.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clone-an-existing-profile\u0022\u003EClone an Existing Profile\u003C/h3\u003E\n\u003Cp\u003EA new profile can be created using an existing profile as its source.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.CreateMyProfileAsync(\n new CreateProfileRequest\n {\n ProfileKey = businessKey,\n CloneFrom = ProfileKey.Default\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECloneFrom\u003C/code\u003E identifies the source profile.\u003C/p\u003E\n\u003Cp\u003EThis is useful when creating a new profile that should initially reuse information from an existing one.\u003C/p\u003E\n\u003Cp\u003EThe exact merge and override behavior is determined by the server\u0027s profile creation implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-creation-events\u0022\u003EProfile Creation Events\u003C/h3\u003E\n\u003Cp\u003EAfter successful self-service profile creation, the SDK publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis allows application components to refresh profile-related views.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-a-specific-profile\u0022\u003E7. Update a Specific Profile\u003C/h2\u003E\n\u003Cp\u003ESet \u003Ccode\u003EProfileKey\u003C/code\u003E in \u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E to select the profile being updated.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;John Doe Consulting\u0026quot;,\n Bio = \u0026quot;Updated business profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same replacement semantics apply to custom profiles.\u003C/p\u003E\n\u003Cp\u003EIf the application intends to preserve existing fields, retrieve the selected profile first and include the values that should remain unchanged.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-profile\u0022\u003E8. Delete a Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMyProfileAsync\u003C/code\u003E to remove a custom profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.DeleteMyProfileAsync(\n new DeleteProfileRequest\n {\n ProfileKey = businessKey\n });\n\nif (result.IsSuccess)\n{\n // Profile deleted.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EDeleting a profile is different from deleting the user account.\u003C/p\u003E\n\u003Cp\u003EThe user identity and other profiles remain subject to their own lifecycle and server-side state.\u003C/p\u003E\n\u003Cp\u003EOn successful self-service profile deletion, the SDK publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E with the deleted profile key.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-user\u0022\u003E9. Create a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateAsync\u003C/code\u003E to request user creation through the standard user creation endpoint.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsync(\n new CreateUserRequest\n {\n UserName = \u0026quot;johndoe\u0026quot;,\n Email = \u0026quot;john@example.com\u0026quot;,\n Password = password,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;,\n DisplayName = \u0026quot;John Doe\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine($\u0026quot;Created user: {created.UserKey}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserCreateResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUser creation availability depends on the server\u0027s configuration and access policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022createuserrequest\u0022\u003ECreateUserRequest\u003C/h3\u003E\n\u003Cp\u003EThe request supports several groups of fields:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C/th\u003E\n\u003Cth\u003EProperties\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifiers\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E, \u003Ccode\u003EEmail\u003C/code\u003E, \u003Ccode\u003EPhone\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EVerification flags\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserNameVerified\u003C/code\u003E, \u003Ccode\u003EEmailVerified\u003C/code\u003E, \u003Ccode\u003EPhoneVerified\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredentials\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProfile\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFirstName\u003C/code\u003E, \u003Ccode\u003ELastName\u003C/code\u003E, \u003Ccode\u003EDisplayName\u003C/code\u003E, \u003Ccode\u003EBirthDate\u003C/code\u003E, \u003Ccode\u003EGender\u003C/code\u003E, \u003Ccode\u003EBio\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPreferences\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ELanguage\u003C/code\u003E, \u003Ccode\u003ETimeZone\u003C/code\u003E, \u003Ccode\u003ECulture\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EExtensions\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EVerification flags must not be treated as client-controlled proof of identifier ownership.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing verification requirements and determining whether a caller is authorized to establish verified identifier state.\u003C/p\u003E\n\u003Cp\u003EFor identifier-specific operations and verification workflows, see \u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022usercreateresult\u0022\u003EUserCreateResult\u003C/h3\u003E\n\u003Cp\u003EThe returned value includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserCreateResult\n{\n public required bool IsSuccess { get; init; }\n public string? UserKey { get; init; }\n public string? FailureReason { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUserKey\u003C/code\u003E is returned as a string when creation succeeds.\u003C/p\u003E\n\u003Cp\u003ECheck both the transport-level \u003Ccode\u003EUAuthResult.IsSuccess\u003C/code\u003E and the operation-level \u003Ccode\u003EUserCreateResult.IsSuccess\u003C/code\u003E where applicable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-user-creation\u0022\u003E10. Administrative User Creation\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateAsAdminAsync\u003C/code\u003E when creating a user through an administrative context.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsAdminAsync(\n new CreateUserRequest\n {\n UserName = \u0026quot;newemployee\u0026quot;,\n Email = \u0026quot;employee@example.com\u0026quot;,\n DisplayName = \u0026quot;New Employee\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine(created.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative creation is distinct from ordinary user creation.\u003C/p\u003E\n\u003Cp\u003EThe server enforces the required permissions and applies the relevant user creation policies.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that calling the administrative endpoint bypasses validation or identifier ownership rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022query-users\u0022\u003E11. Query Users\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EQueryAsync\u003C/code\u003E to search and paginate users.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Search = \u0026quot;john\u0026quot;,\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var user in page.Items)\n {\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;UserSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userquery-filters\u0022\u003EUserQuery Filters\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUserQuery\u003C/code\u003E extends \u003Ccode\u003EPageRequest\u003C/code\u003E and supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESearch\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESearch term\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFilter by user status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeDeleted\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude deleted users when authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESelect the profile context for the query\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPage number\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPage size\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sort field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022search-behavior\u0022\u003ESearch Behavior\u003C/h3\u003E\n\u003Cp\u003EThe query model documents case-insensitive partial matching for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDisplay name.\u003C/li\u003E\n\u003Cli\u003EUsername.\u003C/li\u003E\n\u003Cli\u003EPrimary email.\u003C/li\u003E\n\u003Cli\u003EPrimary phone.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA complete valid \u003Ccode\u003EUserKey\u003C/code\u003E can also be used as a search term.\u003C/p\u003E\n\u003Cp\u003EPartial \u003Ccode\u003EUserKey\u003C/code\u003E matching is not supported.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022filter-by-status\u0022\u003EFilter by Status\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Status = UserStatus.Active,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022include-deleted-users\u0022\u003EInclude Deleted Users\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n IncludeDeleted = true,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe ability to query deleted accounts depends on server-side authorization and data retention behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022usersummary-and-pagination\u0022\u003E12. UserSummary and Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EQueryAsync\u003C/code\u003E returns \u003Ccode\u003EUserSummary\u003C/code\u003E items.\u003C/p\u003E\n\u003Cp\u003EEach summary contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDisplayName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDisplay name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryEmail\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary email\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryPhone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary phone\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis model is suitable for administrative user lists.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n PageNumber = 2,\n PageSize = 25\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n Console.WriteLine($\u0026quot;Total users: {page.TotalCount}\u0026quot;);\n Console.WriteLine($\u0026quot;Current page: {page.PageNumber}\u0026quot;);\n Console.WriteLine($\u0026quot;Has next page: {page.HasNext}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E rather than assuming every query returns the complete user collection.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-users-by-keys\u0022\u003E13. Retrieve Users by Keys\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E to retrieve multiple user summaries when their \u003Ccode\u003EUserKey\u003C/code\u003E values are already known.\u003C/p\u003E\n\u003Cp\u003EUnlike \u003Ccode\u003EQueryAsync\u003C/code\u003E, which searches and paginates users, \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E retrieves a specific collection of users in a single request.\u003C/p\u003E\n\u003Cp\u003EThis is particularly useful for applications that maintain relationships between users, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ETeam members.\u003C/li\u003E\n\u003Cli\u003EOrganization members.\u003C/li\u003E\n\u003Cli\u003EProject participants.\u003C/li\u003E\n\u003Cli\u003EGroup memberships.\u003C/li\u003E\n\u003Cli\u003EUsers assigned to a particular resource.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EInstead of requesting each user\u0027s information separately, the application can retrieve the required summaries in one operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic-usage\u0022\u003EBasic Usage\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar userKeys = new[]\n{\n firstUserKey,\n secondUserKey,\n thirdUserKey\n};\n\nvar result = await UAuthClient.Users.GetByKeysAsync(userKeys);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } users)\n{\n foreach (var user in users)\n {\n Console.WriteLine($\u0026quot;{user.DisplayName} ({user.UserName})\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;IReadOnlyList\u0026lt;UserSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach returned item uses the same \u003Ccode\u003EUserSummary\u003C/code\u003E model described above.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022batch-size-and-behavior\u0022\u003EBatch Size and Behavior\u003C/h3\u003E\n\u003Cp\u003EA single request supports up to \u003Cstrong\u003E250 distinct user keys\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe operation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAccepts a collection of strongly typed \u003Ccode\u003EUserKey\u003C/code\u003E values.\u003C/li\u003E\n\u003Cli\u003ERemoves duplicate keys.\u003C/li\u003E\n\u003Cli\u003EReturns existing, non-deleted users within the applicable tenant.\u003C/li\u003E\n\u003Cli\u003EOmits user keys that do not match an available user.\u003C/li\u003E\n\u003Cli\u003EReturns an empty collection when no users match.\u003C/li\u003E\n\u003Cli\u003EDoes not use pagination.\u003C/li\u003E\n\u003Cli\u003EDoes not guarantee that the response follows the order of the requested keys.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERequests exceeding 250 distinct keys are rejected with the \u003Ccode\u003Euser_batch_size_exceeded\u003C/code\u003E validation error.\u003C/p\u003E\n\u003Cp\u003EFor larger collections, split the keys into batches:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar allUsers = new List\u0026lt;UserSummary\u0026gt;();\n\nforeach (var batch in userKeys.Distinct().Chunk(250))\n{\n var result = await UAuthClient.Users.GetByKeysAsync(batch);\n\n if (!result.IsSuccess || result.Value is null)\n {\n // Handle the failed batch.\n return;\n }\n\n allUsers.AddRange(result.Value);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor large datasets that require searching, filtering, sorting, or pagination, use \u003Ccode\u003EQueryAsync\u003C/code\u003E instead.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022queryasync-vs.getbykeysasync\u0022\u003EQueryAsync vs. GetByKeysAsync\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECapability\u003C/th\u003E\n\u003Cth\u003EQueryAsync\u003C/th\u003E\n\u003Cth\u003EGetByKeysAsync\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESearch users\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERetrieve known user keys\u003C/td\u003E\n\u003Ctd\u003EIndirectly\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPagination\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFiltering and sorting\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple specific users\u003C/td\u003E\n\u003Ctd\u003ENot optimized for this purpose\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMaximum batch size\u003C/td\u003E\n\u003Ctd\u003EPagination limits\u003C/td\u003E\n\u003Ctd\u003E250 distinct keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResponse\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagedResult\u0026lt;UserSummary\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIReadOnlyList\u0026lt;UserSummary\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization\u0022\u003EAuthorization\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EGetByKeysAsync\u003C/code\u003E is a protected operation.\u003C/p\u003E\n\u003Cp\u003EIt uses the action:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthActions.Users.GetByKeysAdmin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe corresponding permission value is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eusers.getbykeys.admin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlthough the action uses the \u003Ccode\u003E.admin\u003C/code\u003E suffix, access is determined by the server\u0027s authorization policies and the actor\u0027s effective permissions.\u003C/p\u003E\n\u003Cp\u003EApplications can authorize non-administrator users to retrieve specific groups of users when their policies explicitly allow it.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may allow team members to retrieve summaries of other members belonging to the same team while denying access to users outside that team.\u003C/p\u003E\n\u003Cp\u003EThe server exposes the requested users through \u003Ccode\u003EAccessContext.TargetUserKeys\u003C/code\u003E, allowing application policies to validate the complete target collection.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Possessing the \u003Ccode\u003Eusers.getbykeys.admin\u003C/code\u003E permission does not automatically establish that every requested user belongs to an allowed group. Applications requiring group, organization, or resource-level isolation must enforce those restrictions through server-side authorization policies.\u003C/p\u003E\n\u003Cp\u003EFor details, see \u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022performance-considerations\u0022\u003EPerformance Considerations\u003C/h3\u003E\n\u003Cp\u003EBatch retrieval avoids issuing a separate client request for every user.\u003C/p\u003E\n\u003Cp\u003EThe reference persistence implementations support retrieving multiple user summaries through a single batch operation.\u003C/p\u003E\n\u003Cp\u003EActual database query counts and execution costs depend on the configured persistence provider.\u003C/p\u003E\n\u003Cp\u003EFor example, an application displaying 50 team members can request their summaries together rather than making 50 individual \u003Ccode\u003EGetUserAsync\u003C/code\u003E calls.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E when user keys are already available. Use \u003Ccode\u003EGetUserAsync\u003C/code\u003E when the application needs the detailed profile of one specific user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-a-specific-user\u0022\u003E14. Get a Specific User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserAsync\u003C/code\u003E to retrieve a user\u0027s profile through the administrative API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.GetUserAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } user)\n{\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;UserView\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EWhen no profile is specified, the default profile is selected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-a-specific-user-profile\u0022\u003ERetrieve a Specific User Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.GetUserAsync(\n userKey,\n new GetProfileRequest\n {\n ProfileKey = businessKey\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actor must have permission to access the target user\u0027s requested information.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-another-users-profile\u0022\u003E15. Update Another User\u0027s Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateUserProfileAsync\u003C/code\u003E to update a selected user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.UpdateUserProfileAsync(\n userKey,\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;Updated Display Name\u0026quot;,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation uses the same replacement semantics as \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EOmitted editable fields may be cleared.\u003C/p\u003E\n\u003Cp\u003EFor partial UI editing, retrieve the target profile and preserve values that should not change.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-a-named-profile\u0022\u003EUpdate a Named Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.UpdateUserProfileAsync(userKey,\n new UpdateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;Updated Business Profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative profile updates require the appropriate authorization for the target user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022manage-another-users-profiles\u0022\u003E16. Manage Another User\u0027s Profiles\u003C/h2\u003E\n\u003Cp\u003EAdministrators can create and delete profiles for a selected user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-profile\u0022\u003ECreate a Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.CreateUserProfileAsync(userKey,\n new CreateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;Business Profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-profile-1\u0022\u003EDelete a Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.DeleteUserProfileAsync(userKey,\n new DeleteProfileRequest\n {\n ProfileKey = businessKey\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EAdministrative profile operations do not automatically publish the same local \u003Ccode\u003EProfileChanged\u003C/code\u003E event as self-service profile operations.\u003C/p\u003E\n\u003Cp\u003ERefresh affected administrative UI data explicitly when needed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-status\u0022\u003E17. Understanding User Status\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates account lifecycle status from profile information.\u003C/p\u003E\n\u003Cp\u003EA user may be active, suspended, disabled, locked, awaiting activation, or in another supported lifecycle state.\u003C/p\u003E\n\u003Cp\u003EStatus can affect authentication eligibility, session continuation, and authorization.\u003C/p\u003E\n\u003Cp\u003EHowever, the exact enforcement behavior depends on the configured server policies and authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-statuses\u0022\u003ESelf-Service and Administrative Statuses\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes separate status enums for self-service and administrative operations.\u003C/p\u003E\n\u003Cp\u003EThis prevents the client contract from treating every administrative lifecycle transition as a self-service capability.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESelfAssignableUserStatus\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum SelfAssignableUserStatus\n{\n Active = 0,\n SelfSuspended = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdminAssignableUserStatus\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum AdminAssignableUserStatus\n{\n Active = 0,\n Disabled = 20,\n Suspended = 30,\n Locked = 40,\n RiskHold = 50,\n PendingActivation = 60,\n PendingVerification = 70,\n Unknown = 100\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enums describe requestable statuses.\u003C/p\u003E\n\u003Cp\u003EThey do not mean every transition is permitted in every application context.\u003C/p\u003E\n\u003Cp\u003EThe server validates the requested transition.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-own-status\u0022\u003E18. Change Your Own Status\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E for supported self-service status transitions.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeMyStatusAsync(\n new ChangeUserStatusSelfRequest\n {\n NewStatus = SelfAssignableUserStatus.SelfSuspended\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } change)\n{\n Console.WriteLine($\u0026quot;New status: {change.CurrentStatus}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation can be used to implement account self-suspension.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the transition is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userstatuschangeresult\u0022\u003EUserStatusChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe result model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the status operation succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviousStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStatus before the operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStatus after the operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure description\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } change)\n{\n if (change.IsSuccess)\n {\n Console.WriteLine($\u0026quot;{change.PreviousStatus} -\u0026gt; {change.CurrentStatus}\u0026quot;);\n }\n else\n {\n Console.WriteLine(change.FailureReason ?? \u0026quot;Status change failed.\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe result may also report a successful no-change outcome when the requested state already matches the current state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful SDK operation, \u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is the current client event behavior, even though the operation changes account status rather than editable profile fields.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that a status change always produces a dedicated \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E event.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-another-users-status\u0022\u003E19. Change Another User\u0027s Status\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeUserStatusAsync\u003C/code\u003E for administrative lifecycle management.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Suspended\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } change)\n{\n Console.WriteLine($\u0026quot;Current status: {change.CurrentStatus}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation requires administrative authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-disable-an-account\u0022\u003EExample: Disable an Account\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Disabled\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restore-an-active-status\u0022\u003EExample: Restore an Active Status\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Active\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhether a particular transition is permitted depends on the server\u0027s lifecycle rules.\u003C/p\u003E\n\u003Cp\u003EDo not assume that setting \u003Ccode\u003EActive\u003C/code\u003E automatically restores every form of previously revoked authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-the-current-user\u0022\u003E20. Delete the Current User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMeAsync\u003C/code\u003E to request deletion of the current account.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteMeAsync();\n\nif (result.IsSuccess)\n{\n // Account deletion succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESelf-service deletion is a soft-delete operation.\u003C/p\u003E\n\u003Cp\u003EAccording to the \u003Ccode\u003EIUserClient\u003C/code\u003E contract, this operation removes associated profiles and sessions.\u003C/p\u003E\n\u003Cp\u003EAfter successful deletion, the client publishes a \u003Ccode\u003EUserDeleted\u003C/code\u003E state event using patch handling mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-considerations\u0022\u003EImportant Considerations\u003C/h3\u003E\n\u003Cp\u003EAccount deletion is not equivalent to ordinary logout.\u003C/p\u003E\n\u003Cp\u003EIt changes the user\u0027s lifecycle state and may affect associated data across multiple domains.\u003C/p\u003E\n\u003Cp\u003EBefore exposing a delete-account action, applications should provide an appropriate confirmation flow and explain the consequences.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for authorization, data cleanup, and deletion policy enforcement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-user-deletion\u0022\u003E21. Administrative User Deletion\u003C/h2\u003E\n\u003Cp\u003EAdministrators can delete users through \u003Ccode\u003EDeleteUserAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe method accepts a \u003Ccode\u003EDeleteUserRequest\u003C/code\u003E specifying the deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022soft-delete\u0022\u003ESoft Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteUserAsync(userKey,\n new DeleteUserRequest\n {\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } deleted)\n{\n Console.WriteLine($\u0026quot;Deletion mode: {deleted.Mode}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteUserAsync(userKey,\n new DeleteUserRequest\n {\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a more destructive operation and should be restricted to appropriately authorized administrative workflows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deletemode\u0022\u003EDeleteMode\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum DeleteMode\n{\n Soft = 0,\n Hard = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userdeleteresult\u0022\u003EUserDeleteResult\u003C/h3\u003E\n\u003Cp\u003EThe operation returns \u003Ccode\u003EUAuthResult\u0026lt;UserDeleteResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUserDeleteResult\u003C/code\u003E includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMode\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EFailureReason\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n // Handle transport or API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } deletion)\n{\n // Handle the operation-level failure.\n Console.WriteLine(deletion.FailureReason);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA previously deleted account may produce an operation-level success result indicating that no additional deletion was necessary.\u003C/p\u003E\n\u003Cp\u003EDo not infer the complete persistence or retention behavior from the \u003Ccode\u003EDeleteMode\u003C/code\u003E value alone.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-user-administration-table\u0022\u003E22. Build a User Administration Table\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates a basic Blazor user list using \u003Ccode\u003EQueryAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Users.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Users\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_search\u0026quot; placeholder=\u0026quot;Search users\u0026quot; /\u0026gt;\n\u0026lt;button @onclick=\u0026quot;SearchAsync\u0026quot;\u0026gt;Search\u0026lt;/button\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading users...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n \u0026lt;table\u0026gt;\n \u0026lt;thead\u0026gt;\n \u0026lt;tr\u0026gt;\n \u0026lt;th\u0026gt;Name\u0026lt;/th\u0026gt;\n \u0026lt;th\u0026gt;Username\u0026lt;/th\u0026gt;\n \u0026lt;th\u0026gt;Status\u0026lt;/th\u0026gt;\n \u0026lt;/tr\u0026gt;\n \u0026lt;/thead\u0026gt;\n \u0026lt;tbody\u0026gt;\n @foreach (var user in _users)\n {\n \u0026lt;tr @key=\u0026quot;user.UserKey\u0026quot;\u0026gt;\n \u0026lt;td\u0026gt;@user.DisplayName\u0026lt;/td\u0026gt;\n \u0026lt;td\u0026gt;@user.UserName\u0026lt;/td\u0026gt;\n \u0026lt;td\u0026gt;@user.Status\u0026lt;/td\u0026gt;\n \u0026lt;/tr\u0026gt;\n }\n \u0026lt;/tbody\u0026gt;\n \u0026lt;/table\u0026gt;\n\n \u0026lt;button @onclick=\u0026quot;PreviousPageAsync\u0026quot; disabled=\u0026quot;@(_pageNumber \u0026lt;= 1)\u0026quot;\u0026gt;Previous\u0026lt;/button\u0026gt;\n \u0026lt;button @onclick=\u0026quot;NextPageAsync\u0026quot; disabled=\u0026quot;@(!_hasNext)\u0026quot;\u0026gt;Next\u0026lt;/button\u0026gt;\n}\n\n@code {\n private string _search = \u0026quot;\u0026quot;;\n private int _pageNumber = 1;\n private bool _hasNext;\n private bool _loading;\n private string? _error;\n private IReadOnlyList\u0026lt;UserSummary\u0026gt; _users = [];\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task SearchAsync()\n {\n _pageNumber = 1;\n await LoadAsync();\n }\n\n private async Task PreviousPageAsync()\n {\n if (_pageNumber \u0026lt;= 1)\n return;\n\n _pageNumber--;\n await LoadAsync();\n }\n\n private async Task NextPageAsync()\n {\n if (!_hasNext)\n return;\n\n _pageNumber\u002B\u002B;\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Search = _search,\n PageNumber = _pageNumber,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _users = page.Items;\n _hasNext = page.HasNext;\n }\n else\n {\n _error = \u0026quot;Unable to load users.\u0026quot;;\n _users = [];\n _hasNext = false;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The user service is unavailable.\u0026quot;;\n _users = [];\n _hasNext = false;\n }\n finally\n {\n _loading = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates searching and pagination.\u003C/p\u003E\n\u003Cp\u003EA production administration interface should additionally include loading indicators, action confirmation, permission-aware UI controls, and appropriate handling for concurrent changes.\u003C/p\u003E\n\u003Cp\u003EAll administrative operations must remain protected by server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-user-operation-results\u0022\u003E23. Handling User Operation Results\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses structured result contracts for user operations.\u003C/p\u003E\n\u003Cp\u003ESome methods return:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOthers return:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;T\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor operations such as user creation, status changes, and deletion, the returned value also contains an operation-level \u003Ccode\u003EIsSuccess\u003C/code\u003E property.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022transport-level-versus-operation-level-success\u0022\u003ETransport-Level Versus Operation-Level Success\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } operation)\n{\n // User creation was processed but did not succeed.\n Console.WriteLine(operation.FailureReason);\n}\nelse if (result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine(created.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck the appropriate success level for the operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E exposes convenient status helpers.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Insufficient permission.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting operation or state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdditional diagnostic fields include \u003Ccode\u003ECorrelationId\u003C/code\u003E, \u003Ccode\u003ETraceId\u003C/code\u003E, \u003Ccode\u003EProblem\u003C/code\u003E, and \u003Ccode\u003EErrorText\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal failure details directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-events\u0022\u003E24. Client State Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthUserClient\u003C/code\u003E publishes state events for successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreateMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMeAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserDeleted\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe event payload depends on the operation.\u003C/p\u003E\n\u003Cp\u003EAdministrative user operations do not automatically publish these same local self-service events.\u003C/p\u003E\n\u003Cp\u003EFor example, after updating another user\u0027s profile, an administration panel should refresh the affected user data as needed.\u003C/p\u003E\n\u003Cp\u003EClient events help synchronize application state, but they do not replace authoritative server-side account status or permission checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-lifecycle-considerations\u0022\u003E25. Security and Lifecycle Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-boundaries\u0022\u003ESelf-Service and Administrative Boundaries\u003C/h3\u003E\n\u003Cp\u003ESelf-service methods operate on the current authenticated user.\u003C/p\u003E\n\u003Cp\u003EAdministrative methods identify an explicit target user.\u003C/p\u003E\n\u003Cp\u003EDo not assume that access to the SDK automatically authorizes an operation.\u003C/p\u003E\n\u003Cp\u003EThe server must enforce actor, target, tenant, and action requirements.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022status-changes-and-existing-sessions\u0022\u003EStatus Changes and Existing Sessions\u003C/h3\u003E\n\u003Cp\u003EChanging account status may affect authentication eligibility and session continuation.\u003C/p\u003E\n\u003Cp\u003EHowever, immediate invalidation of existing credentials depends on the server\u0027s security policy and authentication mode.\u003C/p\u003E\n\u003Cp\u003EA client-side status update is not itself proof that every previously issued token has been invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-data-is-not-authorization-data\u0022\u003EProfile Data Is Not Authorization Data\u003C/h3\u003E\n\u003Cp\u003EA user\u0027s display name, biography, or profile metadata should not be used as proof of permissions.\u003C/p\u003E\n\u003Cp\u003EAuthorization decisions must use the relevant trusted security context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-replacement-can-cause-data-loss\u0022\u003EProfile Replacement Can Cause Data Loss\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E is not a patch contract.\u003C/p\u003E\n\u003Cp\u003ESubmitting an incomplete request may clear existing profile fields.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve intended values and consider concurrency when multiple editors can modify the same profile.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deletion-is-a-lifecycle-operation\u0022\u003EDeletion Is a Lifecycle Operation\u003C/h3\u003E\n\u003Cp\u003ESoft deletion and hard deletion have different implications.\u003C/p\u003E\n\u003Cp\u003EApplications should consider retention, recovery, audit requirements, and dependent data before exposing destructive actions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage usernames, email addresses, phone numbers, and verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, refresh, logout, and authentication state.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions across devices.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand the user domain and lifecycle architecture.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E \u2014 Understand authorization enforcement for user operations.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EThe UltimateAuth Client SDK provides a comprehensive API for managing users, profiles, account status, and user lifecycle operations.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Users\u003C/code\u003E, applications can implement account settings, profile management, user registration, and administrative user management without manually constructing API requests.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between \u003Cstrong\u003Euser identity\u003C/strong\u003E, \u003Cstrong\u003Euser profiles\u003C/strong\u003E, and \u003Cstrong\u003Eaccount lifecycle state\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EA user has a stable identity and may have multiple profiles. Account status controls lifecycle behavior independently of profile data.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the public \u003Ccode\u003EIUserClient\u003C/code\u003E API, including self-service and administrative operations, structured results, pagination, multi-profile support, and important update semantics.\u003C/p\u003E\n\u003Cp\u003EFor email addresses, usernames, phone numbers, and identifier verification, see \u003Ca href=\u0022/docs/client-sdk/identifiers\u0022\u003EIdentifiers\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-user-client\u0022\u003E1. Accessing the User Client\u003C/h2\u003E\n\u003Cp\u003EIn a Blazor component, inject \u003Ccode\u003EIUAuthClient\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUser operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Users\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUsers\u003C/code\u003E property exposes \u003Ccode\u003EIUserClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIts operations are divided into three functional areas:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ECapabilities\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser lifecycle\u003C/td\u003E\n\u003Ctd\u003ECreate users, change status, delete accounts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf-service profiles\u003C/td\u003E\n\u003Ctd\u003ERetrieve, update, create, and delete your own profiles\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAdministration\u003C/td\u003E\n\u003Ctd\u003EQuery users and manage another user\u0027s account and profiles\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ESelf-service methods operate on the authenticated user.\u003C/p\u003E\n\u003Cp\u003EAdministrative methods operate on an explicitly identified target and require the appropriate server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-the-current-user\u0022\u003E2. Get the Current User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMeAsync\u003C/code\u003E to retrieve the current user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.GetMeAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } user)\n{\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n Console.WriteLine(user.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserView\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen no profile is specified, UltimateAuth returns the default profile.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userview\u0022\u003EUserView\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUserView\u003C/code\u003E contains account information and the selected profile\u0027s data.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStable user identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent account status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryEmail\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary email address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryPhone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary phone number\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected profile\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFirstName\u003C/code\u003E, \u003Ccode\u003ELastName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProfile name fields\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDisplayName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDisplay name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBio\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBiography\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBirthDate\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EBirth date\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EGender\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProfile gender field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELanguage\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreferred language\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETimeZone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreferred time zone\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECulture\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECulture preference\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmailVerified\u003C/code\u003E, \u003Ccode\u003EPhoneVerified\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier verification information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount creation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustom profile metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe returned profile is a view of server-managed user information.\u003C/p\u003E\n\u003Cp\u003EIt should not be treated as a substitute for authoritative authentication or authorization state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-the-current-users-profile\u0022\u003E3. Update the Current User\u0027s Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E to update the authenticated user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;John Doe\u0026quot;,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;,\n Language = \u0026quot;en\u0026quot;,\n TimeZone = \u0026quot;Europe/London\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile update succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-profile-updates-replace-editable-fields\u0022\u003EImportant: Profile Updates Replace Editable Fields\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E uses replacement semantics rather than partial-update semantics.\u003C/p\u003E\n\u003Cp\u003EThis means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EEditable fields supplied in the request replace their previous values.\u003C/li\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003Enull\u003C/code\u003E values clear existing values.\u003C/li\u003E\n\u003Cli\u003EOmitted editable properties are also treated as \u003Ccode\u003Enull\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;John Doe\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis does \u003Cstrong\u003Enot\u003C/strong\u003E mean \u201Cchange only the display name.\u201D\u003C/p\u003E\n\u003Cp\u003EOther editable profile fields may be cleared.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended-read-modify-and-submit\u0022\u003ERecommended: Read, Modify, and Submit\u003C/h3\u003E\n\u003Cp\u003EWhen building a profile editor, retrieve the current profile and preserve values that should remain unchanged.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar currentResult = await UAuthClient.Users.GetMeAsync();\n\nif (!currentResult.IsSuccess || currentResult.Value is not { } current)\n{\n return;\n}\n\nvar update = new UpdateProfileRequest\n{\n FirstName = current.FirstName,\n LastName = current.LastName,\n DisplayName = \u0026quot;John Doe\u0026quot;,\n BirthDate = current.BirthDate,\n Gender = current.Gender,\n Bio = current.Bio,\n Language = current.Language,\n TimeZone = current.TimeZone,\n Culture = current.Culture,\n Metadata = current.Metadata\n};\n\nvar result = await UAuthClient.Users.UpdateMyProfileAsync(update);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis preserves the values returned by the initial read, but it is not an atomic patch operation.\u003C/p\u003E\n\u003Cp\u003EIf another request modifies the profile between the read and update, the later replacement may overwrite those changes.\u003C/p\u003E\n\u003Cp\u003EApplications with concurrent profile editing requirements should account for this behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-state-events\u0022\u003EProfile State Events\u003C/h3\u003E\n\u003Cp\u003EAfter a successful \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E operation, the client publishes a \u003Ccode\u003EProfileChanged\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EThe event carries the \u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E used for the operation.\u003C/p\u003E\n\u003Cp\u003EThe configured client state event handling mode determines how the application processes the notification.\u003C/p\u003E\n\u003Cp\u003EThe event is published only when the SDK operation reports success.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-multiple-profiles\u0022\u003E4. Understanding Multiple Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports multiple profiles for a single user when enabled through server configuration.\u003C/p\u003E\n\u003Cp\u003EFor example, one account may have:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n |\n \u002B-- default\n |\n \u002B-- business\n |\n \u002B-- public\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAll profiles belong to the same user identity.\u003C/p\u003E\n\u003Cp\u003EThey do not represent separate authentication accounts or independently authenticated principals.\u003C/p\u003E\n\u003Cp\u003EProfiles allow applications to organize different sets of user-facing information under one account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-profile\u0022\u003EDefault Profile\u003C/h3\u003E\n\u003Cp\u003EEvery newly created user receives a default profile.\u003C/p\u003E\n\u003Cp\u003EIts key is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.Default\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EWhen a profile request omits \u003Ccode\u003EProfileKey\u003C/code\u003E, the default profile is selected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-keys\u0022\u003EProfile Keys\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E is a strongly typed value.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003ETryCreate\u003C/code\u003E to construct a custom key:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n throw new InvalidOperationException(\u0026quot;Invalid profile key.\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EProfile keys are normalized by trimming whitespace and converting the value to lowercase.\u003C/p\u003E\n\u003Cp\u003EA valid key must not be empty or exceed 64 characters.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-a-specific-profile\u0022\u003E5. Retrieve a Specific Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetProfileRequest\u003C/code\u003E to select a profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n return;\n}\n\nvar result = await UAuthClient.Users.GetMeAsync(\n new GetProfileRequest\n {\n ProfileKey = profileKey\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } profile)\n{\n Console.WriteLine(profile.DisplayName);\n Console.WriteLine(profile.ProfileKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response is still \u003Ccode\u003EUserView\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe account identity and status remain associated with the same user; the selected profile determines which profile information is returned.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-an-additional-profile\u0022\u003E6. Create an Additional Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateMyProfileAsync\u003C/code\u003E to create a new profile for the authenticated user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!ProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var profileKey))\n{\n return;\n}\n\nvar result = await UAuthClient.Users.CreateMyProfileAsync(\n new CreateProfileRequest\n {\n ProfileKey = profileKey,\n DisplayName = \u0026quot;John Doe Consulting\u0026quot;,\n Bio = \u0026quot;Business profile\u0026quot;,\n Language = \u0026quot;en\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile created.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECreateProfileRequest\u003C/code\u003E requires \u003Ccode\u003EProfileKey\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also supports the same general profile information used for user-facing profiles.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clone-an-existing-profile\u0022\u003EClone an Existing Profile\u003C/h3\u003E\n\u003Cp\u003EA new profile can be created using an existing profile as its source.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.CreateMyProfileAsync(\n new CreateProfileRequest\n {\n ProfileKey = businessKey,\n CloneFrom = ProfileKey.Default\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECloneFrom\u003C/code\u003E identifies the source profile.\u003C/p\u003E\n\u003Cp\u003EThis is useful when creating a new profile that should initially reuse information from an existing one.\u003C/p\u003E\n\u003Cp\u003EThe exact merge and override behavior is determined by the server\u0027s profile creation implementation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-creation-events\u0022\u003EProfile Creation Events\u003C/h3\u003E\n\u003Cp\u003EAfter successful self-service profile creation, the SDK publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis allows application components to refresh profile-related views.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-a-specific-profile\u0022\u003E7. Update a Specific Profile\u003C/h2\u003E\n\u003Cp\u003ESet \u003Ccode\u003EProfileKey\u003C/code\u003E in \u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E to select the profile being updated.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.UpdateMyProfileAsync(\n new UpdateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;John Doe Consulting\u0026quot;,\n Bio = \u0026quot;Updated business profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe same replacement semantics apply to custom profiles.\u003C/p\u003E\n\u003Cp\u003EIf the application intends to preserve existing fields, retrieve the selected profile first and include the values that should remain unchanged.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-profile\u0022\u003E8. Delete a Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMyProfileAsync\u003C/code\u003E to remove a custom profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.DeleteMyProfileAsync(\n new DeleteProfileRequest\n {\n ProfileKey = businessKey\n });\n\nif (result.IsSuccess)\n{\n // Profile deleted.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EDeleting a profile is different from deleting the user account.\u003C/p\u003E\n\u003Cp\u003EThe user identity and other profiles remain subject to their own lifecycle and server-side state.\u003C/p\u003E\n\u003Cp\u003EOn successful self-service profile deletion, the SDK publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E with the deleted profile key.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-user\u0022\u003E9. Create a User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateAsync\u003C/code\u003E to request user creation through the standard user creation endpoint.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsync(\n new CreateUserRequest\n {\n UserName = \u0026quot;johndoe\u0026quot;,\n Email = \u0026quot;john@example.com\u0026quot;,\n Password = password,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;,\n DisplayName = \u0026quot;John Doe\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine($\u0026quot;Created user: {created.UserKey}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;UserCreateResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUser creation availability depends on the server\u0027s configuration and access policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022createuserrequest\u0022\u003ECreateUserRequest\u003C/h3\u003E\n\u003Cp\u003EThe request supports several groups of fields:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C/th\u003E\n\u003Cth\u003EProperties\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifiers\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E, \u003Ccode\u003EEmail\u003C/code\u003E, \u003Ccode\u003EPhone\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EVerification flags\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserNameVerified\u003C/code\u003E, \u003Ccode\u003EEmailVerified\u003C/code\u003E, \u003Ccode\u003EPhoneVerified\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredentials\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassword\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProfile\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFirstName\u003C/code\u003E, \u003Ccode\u003ELastName\u003C/code\u003E, \u003Ccode\u003EDisplayName\u003C/code\u003E, \u003Ccode\u003EBirthDate\u003C/code\u003E, \u003Ccode\u003EGender\u003C/code\u003E, \u003Ccode\u003EBio\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPreferences\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ELanguage\u003C/code\u003E, \u003Ccode\u003ETimeZone\u003C/code\u003E, \u003Ccode\u003ECulture\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EExtensions\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EMetadata\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EVerification flags must not be treated as client-controlled proof of identifier ownership.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing verification requirements and determining whether a caller is authorized to establish verified identifier state.\u003C/p\u003E\n\u003Cp\u003EFor identifier-specific operations and verification workflows, see \u003Ca href=\u0022/docs/client-sdk/identifiers\u0022\u003EIdentifiers\u003C/a\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022usercreateresult\u0022\u003EUserCreateResult\u003C/h3\u003E\n\u003Cp\u003EThe returned value includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record UserCreateResult\n{\n public required bool IsSuccess { get; init; }\n public string? UserKey { get; init; }\n public string? FailureReason { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EUserKey\u003C/code\u003E is returned as a string when creation succeeds.\u003C/p\u003E\n\u003Cp\u003ECheck both the transport-level \u003Ccode\u003EUAuthResult.IsSuccess\u003C/code\u003E and the operation-level \u003Ccode\u003EUserCreateResult.IsSuccess\u003C/code\u003E where applicable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-user-creation\u0022\u003E10. Administrative User Creation\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ECreateAsAdminAsync\u003C/code\u003E when creating a user through an administrative context.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsAdminAsync(\n new CreateUserRequest\n {\n UserName = \u0026quot;newemployee\u0026quot;,\n Email = \u0026quot;employee@example.com\u0026quot;,\n DisplayName = \u0026quot;New Employee\u0026quot;\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine(created.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative creation is distinct from ordinary user creation.\u003C/p\u003E\n\u003Cp\u003EThe server enforces the required permissions and applies the relevant user creation policies.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that calling the administrative endpoint bypasses validation or identifier ownership rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022query-users\u0022\u003E11. Query Users\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EQueryAsync\u003C/code\u003E to search and paginate users.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Search = \u0026quot;john\u0026quot;,\n PageNumber = 1,\n PageSize = 20\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var user in page.Items)\n {\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;UserSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userquery-filters\u0022\u003EUserQuery Filters\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUserQuery\u003C/code\u003E extends \u003Ccode\u003EPageRequest\u003C/code\u003E and supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESearch\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESearch term\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFilter by user status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeDeleted\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude deleted users when authorized\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESelect the profile context for the query\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPage number\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPage size\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sort field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022search-behavior\u0022\u003ESearch Behavior\u003C/h3\u003E\n\u003Cp\u003EThe query model documents case-insensitive partial matching for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDisplay name.\u003C/li\u003E\n\u003Cli\u003EUsername.\u003C/li\u003E\n\u003Cli\u003EPrimary email.\u003C/li\u003E\n\u003Cli\u003EPrimary phone.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA complete valid \u003Ccode\u003EUserKey\u003C/code\u003E can also be used as a search term.\u003C/p\u003E\n\u003Cp\u003EPartial \u003Ccode\u003EUserKey\u003C/code\u003E matching is not supported.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022filter-by-status\u0022\u003EFilter by Status\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Status = UserStatus.Active,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022include-deleted-users\u0022\u003EInclude Deleted Users\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n IncludeDeleted = true,\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe ability to query deleted accounts depends on server-side authorization and data retention behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022usersummary-and-pagination\u0022\u003E12. UserSummary and Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EQueryAsync\u003C/code\u003E returns \u003Ccode\u003EUserSummary\u003C/code\u003E items.\u003C/p\u003E\n\u003Cp\u003EEach summary contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserKey\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDisplayName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDisplay name\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryEmail\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary email\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryPhone\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary phone\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount status\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECreation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis model is suitable for administrative user lists.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n PageNumber = 2,\n PageSize = 25\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n Console.WriteLine($\u0026quot;Total users: {page.TotalCount}\u0026quot;);\n Console.WriteLine($\u0026quot;Current page: {page.PageNumber}\u0026quot;);\n Console.WriteLine($\u0026quot;Has next page: {page.HasNext}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse \u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E rather than assuming every query returns the complete user collection.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-users-by-keys\u0022\u003E13. Retrieve Users by Keys\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E to retrieve multiple user summaries when their \u003Ccode\u003EUserKey\u003C/code\u003E values are already known.\u003C/p\u003E\n\u003Cp\u003EUnlike \u003Ccode\u003EQueryAsync\u003C/code\u003E, which searches and paginates users, \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E retrieves a specific collection of users in a single request.\u003C/p\u003E\n\u003Cp\u003EThis is particularly useful for applications that maintain relationships between users, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ETeam members.\u003C/li\u003E\n\u003Cli\u003EOrganization members.\u003C/li\u003E\n\u003Cli\u003EProject participants.\u003C/li\u003E\n\u003Cli\u003EGroup memberships.\u003C/li\u003E\n\u003Cli\u003EUsers assigned to a particular resource.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EInstead of requesting each user\u0027s information separately, the application can retrieve the required summaries in one operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic-usage\u0022\u003EBasic Usage\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar userKeys = new[]\n{\n firstUserKey,\n secondUserKey,\n thirdUserKey\n};\n\nvar result = await UAuthClient.Users.GetByKeysAsync(userKeys);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } users)\n{\n foreach (var user in users)\n {\n Console.WriteLine($\u0026quot;{user.DisplayName} ({user.UserName})\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;IReadOnlyList\u0026lt;UserSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach returned item uses the same \u003Ccode\u003EUserSummary\u003C/code\u003E model described above.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022batch-size-and-behavior\u0022\u003EBatch Size and Behavior\u003C/h3\u003E\n\u003Cp\u003EA single request supports up to \u003Cstrong\u003E250 distinct user keys\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe operation:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAccepts a collection of strongly typed \u003Ccode\u003EUserKey\u003C/code\u003E values.\u003C/li\u003E\n\u003Cli\u003ERemoves duplicate keys.\u003C/li\u003E\n\u003Cli\u003EReturns existing, non-deleted users within the applicable tenant.\u003C/li\u003E\n\u003Cli\u003EOmits user keys that do not match an available user.\u003C/li\u003E\n\u003Cli\u003EReturns an empty collection when no users match.\u003C/li\u003E\n\u003Cli\u003EDoes not use pagination.\u003C/li\u003E\n\u003Cli\u003EDoes not guarantee that the response follows the order of the requested keys.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERequests exceeding 250 distinct keys are rejected with the \u003Ccode\u003Euser_batch_size_exceeded\u003C/code\u003E validation error.\u003C/p\u003E\n\u003Cp\u003EFor larger collections, split the keys into batches:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar allUsers = new List\u0026lt;UserSummary\u0026gt;();\n\nforeach (var batch in userKeys.Distinct().Chunk(250))\n{\n var result = await UAuthClient.Users.GetByKeysAsync(batch);\n\n if (!result.IsSuccess || result.Value is null)\n {\n // Handle the failed batch.\n return;\n }\n\n allUsers.AddRange(result.Value);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor large datasets that require searching, filtering, sorting, or pagination, use \u003Ccode\u003EQueryAsync\u003C/code\u003E instead.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022queryasync-vs.getbykeysasync\u0022\u003EQueryAsync vs. GetByKeysAsync\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECapability\u003C/th\u003E\n\u003Cth\u003EQueryAsync\u003C/th\u003E\n\u003Cth\u003EGetByKeysAsync\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESearch users\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERetrieve known user keys\u003C/td\u003E\n\u003Ctd\u003EIndirectly\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPagination\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFiltering and sorting\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple specific users\u003C/td\u003E\n\u003Ctd\u003ENot optimized for this purpose\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMaximum batch size\u003C/td\u003E\n\u003Ctd\u003EPagination limits\u003C/td\u003E\n\u003Ctd\u003E250 distinct keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResponse\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagedResult\u0026lt;UserSummary\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EIReadOnlyList\u0026lt;UserSummary\u0026gt;\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization\u0022\u003EAuthorization\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EGetByKeysAsync\u003C/code\u003E is a protected operation.\u003C/p\u003E\n\u003Cp\u003EIt uses the action:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthActions.Users.GetByKeysAdmin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe corresponding permission value is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eusers.getbykeys.admin\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlthough the action uses the \u003Ccode\u003E.admin\u003C/code\u003E suffix, access is determined by the server\u0027s authorization policies and the actor\u0027s effective permissions.\u003C/p\u003E\n\u003Cp\u003EApplications can authorize non-administrator users to retrieve specific groups of users when their policies explicitly allow it.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may allow team members to retrieve summaries of other members belonging to the same team while denying access to users outside that team.\u003C/p\u003E\n\u003Cp\u003EThe server exposes the requested users through \u003Ccode\u003EAccessContext.TargetUserKeys\u003C/code\u003E, allowing application policies to validate the complete target collection.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Possessing the \u003Ccode\u003Eusers.getbykeys.admin\u003C/code\u003E permission does not automatically establish that every requested user belongs to an allowed group. Applications requiring group, organization, or resource-level isolation must enforce those restrictions through server-side authorization policies.\u003C/p\u003E\n\u003Cp\u003EFor details, see \u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022performance-considerations\u0022\u003EPerformance Considerations\u003C/h3\u003E\n\u003Cp\u003EBatch retrieval avoids issuing a separate client request for every user.\u003C/p\u003E\n\u003Cp\u003EThe reference persistence implementations support retrieving multiple user summaries through a single batch operation.\u003C/p\u003E\n\u003Cp\u003EActual database query counts and execution costs depend on the configured persistence provider.\u003C/p\u003E\n\u003Cp\u003EFor example, an application displaying 50 team members can request their summaries together rather than making 50 individual \u003Ccode\u003EGetUserAsync\u003C/code\u003E calls.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetByKeysAsync\u003C/code\u003E when user keys are already available. Use \u003Ccode\u003EGetUserAsync\u003C/code\u003E when the application needs the detailed profile of one specific user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022get-a-specific-user\u0022\u003E14. Get a Specific User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserAsync\u003C/code\u003E to retrieve a user\u0027s profile through the administrative API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.GetUserAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } user)\n{\n Console.WriteLine(user.DisplayName);\n Console.WriteLine(user.Status);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;UserView\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EWhen no profile is specified, the default profile is selected.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022retrieve-a-specific-user-profile\u0022\u003ERetrieve a Specific User Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.GetUserAsync(\n userKey,\n new GetProfileRequest\n {\n ProfileKey = businessKey\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe actor must have permission to access the target user\u0027s requested information.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-another-users-profile\u0022\u003E15. Update Another User\u0027s Profile\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EUpdateUserProfileAsync\u003C/code\u003E to update a selected user\u0027s profile.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.UpdateUserProfileAsync(\n userKey,\n new UpdateProfileRequest\n {\n DisplayName = \u0026quot;Updated Display Name\u0026quot;,\n FirstName = \u0026quot;John\u0026quot;,\n LastName = \u0026quot;Doe\u0026quot;\n });\n\nif (result.IsSuccess)\n{\n // Profile updated.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation uses the same replacement semantics as \u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EOmitted editable fields may be cleared.\u003C/p\u003E\n\u003Cp\u003EFor partial UI editing, retrieve the target profile and preserve values that should not change.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022update-a-named-profile\u0022\u003EUpdate a Named Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.UpdateUserProfileAsync(userKey,\n new UpdateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;Updated Business Profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdministrative profile updates require the appropriate authorization for the target user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022manage-another-users-profiles\u0022\u003E16. Manage Another User\u0027s Profiles\u003C/h2\u003E\n\u003Cp\u003EAdministrators can create and delete profiles for a selected user.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-profile\u0022\u003ECreate a Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.CreateUserProfileAsync(userKey,\n new CreateProfileRequest\n {\n ProfileKey = businessKey,\n DisplayName = \u0026quot;Business Profile\u0026quot;\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-a-profile-1\u0022\u003EDelete a Profile\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EProfileKey.TryCreate(\u0026quot;business\u0026quot;, out var businessKey);\n\nvar result = await UAuthClient.Users.DeleteUserProfileAsync(userKey,\n new DeleteProfileRequest\n {\n ProfileKey = businessKey\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default profile cannot be deleted.\u003C/p\u003E\n\u003Cp\u003EAdministrative profile operations do not automatically publish the same local \u003Ccode\u003EProfileChanged\u003C/code\u003E event as self-service profile operations.\u003C/p\u003E\n\u003Cp\u003ERefresh affected administrative UI data explicitly when needed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-status\u0022\u003E17. Understanding User Status\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates account lifecycle status from profile information.\u003C/p\u003E\n\u003Cp\u003EA user may be active, suspended, disabled, locked, awaiting activation, or in another supported lifecycle state.\u003C/p\u003E\n\u003Cp\u003EStatus can affect authentication eligibility, session continuation, and authorization.\u003C/p\u003E\n\u003Cp\u003EHowever, the exact enforcement behavior depends on the configured server policies and authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-statuses\u0022\u003ESelf-Service and Administrative Statuses\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes separate status enums for self-service and administrative operations.\u003C/p\u003E\n\u003Cp\u003EThis prevents the client contract from treating every administrative lifecycle transition as a self-service capability.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESelfAssignableUserStatus\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum SelfAssignableUserStatus\n{\n Active = 0,\n SelfSuspended = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdminAssignableUserStatus\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum AdminAssignableUserStatus\n{\n Active = 0,\n Disabled = 20,\n Suspended = 30,\n Locked = 40,\n RiskHold = 50,\n PendingActivation = 60,\n PendingVerification = 70,\n Unknown = 100\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese enums describe requestable statuses.\u003C/p\u003E\n\u003Cp\u003EThey do not mean every transition is permitted in every application context.\u003C/p\u003E\n\u003Cp\u003EThe server validates the requested transition.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-your-own-status\u0022\u003E18. Change Your Own Status\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E for supported self-service status transitions.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeMyStatusAsync(\n new ChangeUserStatusSelfRequest\n {\n NewStatus = SelfAssignableUserStatus.SelfSuspended\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } change)\n{\n Console.WriteLine($\u0026quot;New status: {change.CurrentStatus}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation can be used to implement account self-suspension.\u003C/p\u003E\n\u003Cp\u003EThe server determines whether the transition is permitted.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userstatuschangeresult\u0022\u003EUserStatusChangeResult\u003C/h3\u003E\n\u003Cp\u003EThe result model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the status operation succeeded\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreviousStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStatus before the operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECurrentStatus\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EStatus after the operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureReason\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional failure description\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } change)\n{\n if (change.IsSuccess)\n {\n Console.WriteLine($\u0026quot;{change.PreviousStatus} -\u0026gt; {change.CurrentStatus}\u0026quot;);\n }\n else\n {\n Console.WriteLine(change.FailureReason ?? \u0026quot;Status change failed.\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe result may also report a successful no-change outcome when the requested state already matches the current state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022state-event\u0022\u003EState Event\u003C/h3\u003E\n\u003Cp\u003EAfter a successful SDK operation, \u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E publishes \u003Ccode\u003EProfileChanged\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is the current client event behavior, even though the operation changes account status rather than editable profile fields.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that a status change always produces a dedicated \u003Ccode\u003EAuthorizationChanged\u003C/code\u003E event.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-another-users-status\u0022\u003E19. Change Another User\u0027s Status\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EChangeUserStatusAsync\u003C/code\u003E for administrative lifecycle management.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Suspended\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } change)\n{\n Console.WriteLine($\u0026quot;Current status: {change.CurrentStatus}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis operation requires administrative authorization.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-disable-an-account\u0022\u003EExample: Disable an Account\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Disabled\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restore-an-active-status\u0022\u003EExample: Restore an Active Status\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.ChangeUserStatusAsync(userKey,\n new ChangeUserStatusAdminRequest\n {\n NewStatus = AdminAssignableUserStatus.Active\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhether a particular transition is permitted depends on the server\u0027s lifecycle rules.\u003C/p\u003E\n\u003Cp\u003EDo not assume that setting \u003Ccode\u003EActive\u003C/code\u003E automatically restores every form of previously revoked authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022delete-the-current-user\u0022\u003E20. Delete the Current User\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EDeleteMeAsync\u003C/code\u003E to request deletion of the current account.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteMeAsync();\n\nif (result.IsSuccess)\n{\n // Account deletion succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESelf-service deletion is a soft-delete operation.\u003C/p\u003E\n\u003Cp\u003EAccording to the \u003Ccode\u003EIUserClient\u003C/code\u003E contract, this operation removes associated profiles and sessions.\u003C/p\u003E\n\u003Cp\u003EAfter successful deletion, the client publishes a \u003Ccode\u003EUserDeleted\u003C/code\u003E state event using patch handling mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-considerations\u0022\u003EImportant Considerations\u003C/h3\u003E\n\u003Cp\u003EAccount deletion is not equivalent to ordinary logout.\u003C/p\u003E\n\u003Cp\u003EIt changes the user\u0027s lifecycle state and may affect associated data across multiple domains.\u003C/p\u003E\n\u003Cp\u003EBefore exposing a delete-account action, applications should provide an appropriate confirmation flow and explain the consequences.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for authorization, data cleanup, and deletion policy enforcement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-user-deletion\u0022\u003E21. Administrative User Deletion\u003C/h2\u003E\n\u003Cp\u003EAdministrators can delete users through \u003Ccode\u003EDeleteUserAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe method accepts a \u003Ccode\u003EDeleteUserRequest\u003C/code\u003E specifying the deletion mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022soft-delete\u0022\u003ESoft Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteUserAsync(userKey,\n new DeleteUserRequest\n {\n Mode = DeleteMode.Soft\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsSuccess: true } deleted)\n{\n Console.WriteLine($\u0026quot;Deletion mode: {deleted.Mode}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hard-delete\u0022\u003EHard Delete\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.DeleteUserAsync(userKey,\n new DeleteUserRequest\n {\n Mode = DeleteMode.Hard\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EHard deletion is a more destructive operation and should be restricted to appropriately authorized administrative workflows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deletemode\u0022\u003EDeleteMode\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic enum DeleteMode\n{\n Soft = 0,\n Hard = 10\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022userdeleteresult\u0022\u003EUserDeleteResult\u003C/h3\u003E\n\u003Cp\u003EThe operation returns \u003Ccode\u003EUAuthResult\u0026lt;UserDeleteResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUserDeleteResult\u003C/code\u003E includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMode\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EFailureReason\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (!result.IsSuccess)\n{\n // Handle transport or API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } deletion)\n{\n // Handle the operation-level failure.\n Console.WriteLine(deletion.FailureReason);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA previously deleted account may produce an operation-level success result indicating that no additional deletion was necessary.\u003C/p\u003E\n\u003Cp\u003EDo not infer the complete persistence or retention behavior from the \u003Ccode\u003EDeleteMode\u003C/code\u003E value alone.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-user-administration-table\u0022\u003E22. Build a User Administration Table\u003C/h2\u003E\n\u003Cp\u003EThe following example demonstrates a basic Blazor user list using \u003Ccode\u003EQueryAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Users.Contracts\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;Users\u0026lt;/h3\u0026gt;\n\n\u0026lt;input @bind=\u0026quot;_search\u0026quot; placeholder=\u0026quot;Search users\u0026quot; /\u0026gt;\n\u0026lt;button @onclick=\u0026quot;SearchAsync\u0026quot;\u0026gt;Search\u0026lt;/button\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading users...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n \u0026lt;table\u0026gt;\n \u0026lt;thead\u0026gt;\n \u0026lt;tr\u0026gt;\n \u0026lt;th\u0026gt;Name\u0026lt;/th\u0026gt;\n \u0026lt;th\u0026gt;Username\u0026lt;/th\u0026gt;\n \u0026lt;th\u0026gt;Status\u0026lt;/th\u0026gt;\n \u0026lt;/tr\u0026gt;\n \u0026lt;/thead\u0026gt;\n \u0026lt;tbody\u0026gt;\n @foreach (var user in _users)\n {\n \u0026lt;tr @key=\u0026quot;user.UserKey\u0026quot;\u0026gt;\n \u0026lt;td\u0026gt;@user.DisplayName\u0026lt;/td\u0026gt;\n \u0026lt;td\u0026gt;@user.UserName\u0026lt;/td\u0026gt;\n \u0026lt;td\u0026gt;@user.Status\u0026lt;/td\u0026gt;\n \u0026lt;/tr\u0026gt;\n }\n \u0026lt;/tbody\u0026gt;\n \u0026lt;/table\u0026gt;\n\n \u0026lt;button @onclick=\u0026quot;PreviousPageAsync\u0026quot; disabled=\u0026quot;@(_pageNumber \u0026lt;= 1)\u0026quot;\u0026gt;Previous\u0026lt;/button\u0026gt;\n \u0026lt;button @onclick=\u0026quot;NextPageAsync\u0026quot; disabled=\u0026quot;@(!_hasNext)\u0026quot;\u0026gt;Next\u0026lt;/button\u0026gt;\n}\n\n@code {\n private string _search = \u0026quot;\u0026quot;;\n private int _pageNumber = 1;\n private bool _hasNext;\n private bool _loading;\n private string? _error;\n private IReadOnlyList\u0026lt;UserSummary\u0026gt; _users = [];\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task SearchAsync()\n {\n _pageNumber = 1;\n await LoadAsync();\n }\n\n private async Task PreviousPageAsync()\n {\n if (_pageNumber \u0026lt;= 1)\n return;\n\n _pageNumber--;\n await LoadAsync();\n }\n\n private async Task NextPageAsync()\n {\n if (!_hasNext)\n return;\n\n _pageNumber\u002B\u002B;\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Users.QueryAsync(\n new UserQuery\n {\n Search = _search,\n PageNumber = _pageNumber,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _users = page.Items;\n _hasNext = page.HasNext;\n }\n else\n {\n _error = \u0026quot;Unable to load users.\u0026quot;;\n _users = [];\n _hasNext = false;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The user service is unavailable.\u0026quot;;\n _users = [];\n _hasNext = false;\n }\n finally\n {\n _loading = false;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates searching and pagination.\u003C/p\u003E\n\u003Cp\u003EA production administration interface should additionally include loading indicators, action confirmation, permission-aware UI controls, and appropriate handling for concurrent changes.\u003C/p\u003E\n\u003Cp\u003EAll administrative operations must remain protected by server-side authorization.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-user-operation-results\u0022\u003E23. Handling User Operation Results\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses structured result contracts for user operations.\u003C/p\u003E\n\u003Cp\u003ESome methods return:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOthers return:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;T\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor operations such as user creation, status changes, and deletion, the returned value also contains an operation-level \u003Ccode\u003EIsSuccess\u003C/code\u003E property.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022transport-level-versus-operation-level-success\u0022\u003ETransport-Level Versus Operation-Level Success\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Users.CreateAsync(request);\n\nif (!result.IsSuccess)\n{\n // API-level failure.\n}\nelse if (result.Value is { IsSuccess: false } operation)\n{\n // User creation was processed but did not succeed.\n Console.WriteLine(operation.FailureReason);\n}\nelse if (result.Value is { IsSuccess: true } created)\n{\n Console.WriteLine(created.UserKey);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECheck the appropriate success level for the operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-status-helpers\u0022\u003EHTTP Status Helpers\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E exposes convenient status helpers.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (result.Http.IsUnauthorized)\n{\n // Authentication required.\n}\nelse if (result.Http.IsForbidden)\n{\n // Insufficient permission.\n}\nelse if (result.Http.IsConflict)\n{\n // Conflicting operation or state.\n}\nelse if (result.Http.IsBadRequest)\n{\n // Invalid request.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAdditional diagnostic fields include \u003Ccode\u003ECorrelationId\u003C/code\u003E, \u003Ccode\u003ETraceId\u003C/code\u003E, \u003Ccode\u003EProblem\u003C/code\u003E, and \u003Ccode\u003EErrorText\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EAvoid exposing sensitive internal failure details directly to end users.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-state-events\u0022\u003E24. Client State Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthUserClient\u003C/code\u003E publishes state events for successful self-service operations.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EEvent\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUpdateMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreateMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMyProfileAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChangeMyStatusAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EProfileChanged\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeleteMeAsync\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserDeleted\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe event payload depends on the operation.\u003C/p\u003E\n\u003Cp\u003EAdministrative user operations do not automatically publish these same local self-service events.\u003C/p\u003E\n\u003Cp\u003EFor example, after updating another user\u0027s profile, an administration panel should refresh the affected user data as needed.\u003C/p\u003E\n\u003Cp\u003EClient events help synchronize application state, but they do not replace authoritative server-side account status or permission checks.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-lifecycle-considerations\u0022\u003E25. Security and Lifecycle Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-boundaries\u0022\u003ESelf-Service and Administrative Boundaries\u003C/h3\u003E\n\u003Cp\u003ESelf-service methods operate on the current authenticated user.\u003C/p\u003E\n\u003Cp\u003EAdministrative methods identify an explicit target user.\u003C/p\u003E\n\u003Cp\u003EDo not assume that access to the SDK automatically authorizes an operation.\u003C/p\u003E\n\u003Cp\u003EThe server must enforce actor, target, tenant, and action requirements.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022status-changes-and-existing-sessions\u0022\u003EStatus Changes and Existing Sessions\u003C/h3\u003E\n\u003Cp\u003EChanging account status may affect authentication eligibility and session continuation.\u003C/p\u003E\n\u003Cp\u003EHowever, immediate invalidation of existing credentials depends on the server\u0027s security policy and authentication mode.\u003C/p\u003E\n\u003Cp\u003EA client-side status update is not itself proof that every previously issued token has been invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-data-is-not-authorization-data\u0022\u003EProfile Data Is Not Authorization Data\u003C/h3\u003E\n\u003Cp\u003EA user\u0027s display name, biography, or profile metadata should not be used as proof of permissions.\u003C/p\u003E\n\u003Cp\u003EAuthorization decisions must use the relevant trusted security context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-replacement-can-cause-data-loss\u0022\u003EProfile Replacement Can Cause Data Loss\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EUpdateProfileRequest\u003C/code\u003E is not a patch contract.\u003C/p\u003E\n\u003Cp\u003ESubmitting an incomplete request may clear existing profile fields.\u003C/p\u003E\n\u003Cp\u003EApplications should preserve intended values and consider concurrency when multiple editors can modify the same profile.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022deletion-is-a-lifecycle-operation\u0022\u003EDeletion Is a Lifecycle Operation\u003C/h3\u003E\n\u003Cp\u003ESoft deletion and hard deletion have different implications.\u003C/p\u003E\n\u003Cp\u003EApplications should consider retention, recovery, audit requirements, and dependent data before exposing destructive actions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/identifiers\u0022\u003EIdentifiers\u003C/a\u003E \u2014 Manage usernames, email addresses, phone numbers, and verification.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, refresh, logout, and authentication state.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client-sdk/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Inspect and revoke sessions across devices.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand the user domain and lifecycle architecture.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E \u2014 Understand authorization enforcement for user operations.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "accessing-the-user-client", @@ -339,6 +339,6 @@ "Level": 0 } ], - "SourceHash": "6E53F83F18629B64B09F13E568D5A6D71C6954D07C5842E288430FFB7B526FB6", + "SourceHash": "81CF0482498EB09AACD57DF9BC1FC7D586A11FF62FD14BF9AA97AB3C43C10B0E", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json index d5a6794b..c11b7c67 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json @@ -414,6 +414,6 @@ "Level": 0 } ], - "SourceHash": "FE74B982178EDB99198EC90811ED91660E95FD2EE3D445361B61236F850B608A", + "SourceHash": "8B65E4A762A493F3F5C6A4F6978EE5FCCCE7678BD388239B876754178E5EE3D0", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/overview.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/overview.json index eccab71e..8555cd1c 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/overview.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/overview.json @@ -1,7 +1,7 @@ { "Slug": "configuration/overview", "Title": "Configuration Overview", - "Html": "\n\u003Cp\u003EUltimateAuth provides a structured configuration system for controlling authentication behavior, session lifecycles, token policies, client integration, and server security.\u003C/p\u003E\n\u003Cp\u003EConfiguration is organized around the application components that actually consume it.\u003C/p\u003E\n\u003Cp\u003EFor most applications, the two primary configuration types are:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E\u003C/strong\u003E \u2014 Controls authentication and security behavior on the UltimateAuth server.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EUAuthClientOptions\u003C/code\u003E\u003C/strong\u003E \u2014 Controls how an application client communicates with UltimateAuth and executes authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth also defines reusable options in its Core package. These types form the foundation of server configuration, but they do not need to be configured independently when using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis guide explains where configuration belongs, how options are registered, how configuration sources interact, and how to customize behavior safely.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-architecture\u0022\u003E1. Configuration Architecture\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates reusable option definitions from the runtime components that consume them.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth\n\u2502\n\u251C\u2500\u2500 Core\n\u2502 \u2514\u2500\u2500 Shared option definitions\n\u2502 \u251C\u2500\u2500 Login\n\u2502 \u251C\u2500\u2500 Session\n\u2502 \u251C\u2500\u2500 Token\n\u2502 \u251C\u2500\u2500 PKCE\n\u2502 \u2514\u2500\u2500 Multi-tenancy\n\u2502\n\u251C\u2500\u2500 Server\n\u2502 \u2514\u2500\u2500 UAuthServerOptions\n\u2502 \u251C\u2500\u2500 Core-defined options\n\u2502 \u251C\u2500\u2500 Authentication mode restrictions\n\u2502 \u251C\u2500\u2500 Cookie and session resolution\n\u2502 \u251C\u2500\u2500 Endpoints\n\u2502 \u251C\u2500\u2500 Hub configuration\n\u2502 \u251C\u2500\u2500 Credential policies\n\u2502 \u2514\u2500\u2500 Server-specific behavior\n\u2502\n\u2514\u2500\u2500 Client\n \u2514\u2500\u2500 UAuthClientOptions\n \u251C\u2500\u2500 Client profile\n \u251C\u2500\u2500 Login and PKCE flows\n \u251C\u2500\u2500 Endpoint configuration\n \u251C\u2500\u2500 Auto-refresh\n \u251C\u2500\u2500 Reauthentication\n \u2514\u2500\u2500 Client state events\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe important distinction is between \u003Cstrong\u003Ewhere an option type is defined\u003C/strong\u003E and \u003Cstrong\u003Ewhere its value is consumed\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E is defined in Core, but a normal UltimateAuth Server application configures it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server consumes \u003Ccode\u003Eoptions.Session\u003C/code\u003E from \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThere is no need to configure a separate Core options instance for this setting.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-server\u0022\u003E2. Configure the Server\u003C/h2\u003E\n\u003Cp\u003EThe main server registration method is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Server configuration.\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis registers the UltimateAuth server infrastructure and its configuration.\u003C/p\u003E\n\u003Cp\u003EThe method accepts an optional configuration delegate:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EAction\u0026lt;UAuthServerOptions\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIt also supports a separate optional delegate for registering access policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-authentication-and-session-settings\u0022\u003EExample: Authentication and Session Settings\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n\n options.Session.Lifetime = TimeSpan.FromHours(8);\n\n options.Token.AccessTokenLifetime =\n TimeSpan.FromMinutes(15);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control different parts of the authentication lifecycle:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin.MaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum failed login attempts under the configured login policy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession.Lifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken.AccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its validation rules to the resulting configuration.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EConfiguration values should be chosen together.\u003C/strong\u003E For example, token lifetime settings must remain compatible with the configured session lifetime limits.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-options-are-composed-into-server-options\u0022\u003E3. Core Options Are Composed into Server Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E includes the following Core-defined option groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic UAuthLoginOptions Login { get; set; } = new();\n\npublic UAuthSessionOptions Session { get; set; } = new();\n\npublic UAuthTokenOptions Token { get; set; } = new();\n\npublic UAuthPkceOptions Pkce { get; set; } = new();\n\npublic UAuthMultiTenantOptions MultiTenant { get; set; } = new();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is composition, not duplication.\u003C/p\u003E\n\u003Cp\u003EThe Core package owns the shared option definitions, while the Server package uses those definitions as part of its effective server configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended\u0022\u003ERecommended\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n options.Pkce.Enabled = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022not-recommended-for-server-configuration\u0022\u003ENot Recommended for Server Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuth(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\nbuilder.Services.AddUltimateAuthServer();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe second example configures \u003Ccode\u003EUAuthOptions\u003C/code\u003E, not the \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance consumed by the server\u0027s authentication runtime.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhen using UltimateAuth Server, configure Core-defined behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E or the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EDirect Core configuration is intended for scenarios that explicitly consume \u003Ccode\u003EUAuthOptions\u003C/code\u003E rather than the server configuration model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-configuration-groups\u0022\u003E4. Server Configuration Groups\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E organizes server behavior into focused option groups.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and authentication behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance, lifetimes, and refresh behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE flow configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution and multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestricts permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment arrangement\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub-specific configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication cookie behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolving session identifiers from incoming requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer endpoint configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession handling after credential changes and recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential reset configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential policy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types permitted for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination limits and defaults\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication response behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENavigation and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDiagnostic configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer event configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese groups allow applications to customize specific behavior without replacing the overall authentication architecture.\u003C/p\u003E\n\u003Cp\u003EThe individual option types and their defaults are documented in \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-client\u0022\u003E5. Configure the Client\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients have their own configuration model.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoDetectClientProfile = true;\n options.DefaultReturnUrl = \u0026quot;/\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe registration accepts:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EAction\u0026lt;UAuthClientOptions\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client options control how the client behaves, not the server\u0027s security policy.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoRefresh.Enabled = true;\n options.DefaultReturnUrl = \u0026quot;/dashboard\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEnabling client auto-refresh does not change the server\u0027s refresh token lifetime, session expiration rules, or rotation policy.\u003C/p\u003E\n\u003Cp\u003EThose are server-side responsibilities.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-configuration-groups\u0022\u003EClient Configuration Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExplicit client profile\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAutoDetectClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAutomatic client profile detection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDefault post-authentication navigation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient login flow behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient PKCE flow behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAutoRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAutomatic refresh behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReauth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReauthentication behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer endpoint paths used by the client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient tenant propagation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStateEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient state event handling\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E for the full reference.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-and-client-configuration-are-independent\u0022\u003E6. Server and Client Configuration Are Independent\u003C/h2\u003E\n\u003Cp\u003EA project may register both UltimateAuth Server and UltimateAuth Client.\u003C/p\u003E\n\u003Cp\u003EThis is common in applications that host authentication services and also use the client SDK.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\nbuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoDetectClientProfile = true;\n options.DefaultReturnUrl = \u0026quot;/\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese registrations configure different components.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ESetting\u003C/th\u003E\n\u003Cth\u003EServer\u003C/th\u003E\n\u003Cth\u003EClient\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession lifetime\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EToken lifetime\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential reset policy\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAllowed authentication modes\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient profile\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient auto-refresh\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient navigation\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServer endpoint exposure\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EUses configured routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe client must be compatible with the server\u0027s configuration, but it cannot override server-side security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, enabling PKCE in client options does not make a disabled or unsupported PKCE server flow available.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-sources\u0022\u003E7. Configuration Sources\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports configuration through registration delegates and the standard ASP.NET Core configuration system.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022code-based-configuration\u0022\u003ECode-Based Configuration\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EProgram.cs\u003C/code\u003E for explicit configuration.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when configuration is closely tied to application behavior or requires strongly typed values.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-based-setup\u0022\u003EConfiguration-Based Setup\u003C/h3\u003E\n\u003Cp\u003EThe server binds settings from:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example, in \u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;08:00:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client binds settings from:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Client\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Client\u0026quot;: {\n \u0026quot;AutoDetectClientProfile\u0026quot;: true,\n \u0026quot;DefaultReturnUrl\u0026quot;: \u0026quot;/dashboard\u0026quot;,\n \u0026quot;AutoRefresh\u0026quot;: {\n \u0026quot;Enabled\u0026quot;: true\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese sections are independent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-both-sources\u0022\u003EUsing Both Sources\u003C/h3\u003E\n\u003Cp\u003EYou can combine code-based and configuration-based settings:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 3\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003ELogin.MaxFailedAttempts\u003C/code\u003E is \u003Ccode\u003E3\u003C/code\u003E, because the registration binds the configuration section after applying the configuration delegate.\u003C/p\u003E\n\u003Cp\u003EThe session lifetime remains eight hours because the configuration section does not override it.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-precedence\u0022\u003E8. Configuration Precedence\u003C/h2\u003E\n\u003Cp\u003EThe current registration pipeline applies options in this order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EOption type defaults.\u003C/li\u003E\n\u003Cli\u003EValues supplied by the registration delegate.\u003C/li\u003E\n\u003Cli\u003EValues bound from the corresponding configuration section.\u003C/li\u003E\n\u003Cli\u003EApplicable post-configuration processing.\u003C/li\u003E\n\u003Cli\u003EOptions validation.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor the server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eservices.AddOptions\u0026lt;UAuthServerOptions\u0026gt;()\n .Configure(options =\u0026gt;\n {\n configure?.Invoke(options);\n })\n .BindConfiguration(\u0026quot;UltimateAuth:Server\u0026quot;)\n .ValidateOnStart();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor the client, the corresponding configuration section is \u003Ccode\u003EUltimateAuth:Client\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-distinction\u0022\u003EImportant Distinction\u003C/h3\u003E\n\u003Cp\u003EWhen we say configuration values override \u003Ccode\u003EProgram.cs\u003C/code\u003E, we mean that the \u003Cstrong\u003Elater configuration binding takes precedence for the same property\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe exact value also depends on the normal ASP.NET Core configuration provider order.\u003C/p\u003E\n\u003Cp\u003EFor example, environment-specific configuration files and environment variables may override values from the base \u003Ccode\u003Eappsettings.json\u003C/code\u003E file.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022environment-variable-example\u0022\u003EEnvironment Variable Example\u003C/h3\u003E\n\u003Cp\u003EASP.NET Core configuration supports double underscores as section separators.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth__Server__Login__MaxFailedAttempts=3\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis maps to:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server:Login:MaxFailedAttempts\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a client setting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth__Client__DefaultReturnUrl=/dashboard\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis makes it possible to provide environment-specific settings without modifying application code.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-configuration\u0022\u003E9. Authentication Mode Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports multiple authentication mode concepts.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting defines which modes the server is permitted to use.\u003C/p\u003E\n\u003Cp\u003EIt is a \u003Cstrong\u003Erestriction\u003C/strong\u003E, not a direct mode selector.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved by the server\u0027s authentication mode resolution mechanism.\u003C/p\u003E\n\u003Cp\u003ESetting \u003Ccode\u003EAllowedModes\u003C/code\u003E does not directly choose which mode every client will use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cp\u003EIn the current implementation, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled server modes.\u003C/p\u003E\n\u003Cp\u003EThe server options validator rejects them when explicitly included in \u003Ccode\u003EAllowedModes\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, this configuration is invalid:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not enable an unsupported mode merely because it exists in the enum.\u003C/p\u003E\n\u003Cp\u003EFor supported modes and their behavior, see \u003Ca href=\u0022/docs/fundamentals/authentication-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-credential-session-policies\u0022\u003E10. Configure Credential Session Policies\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports configurable session invalidation after credential changes.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange =\n CredentialSessionBehavior.PreserveCurrent;\n\n options.CredentialSessionPolicy.OnAdminChange =\n CredentialSessionBehavior.RevokeAll;\n\n options.CredentialSessionPolicy.OnRecovery =\n CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe available behaviors are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an example of configuration controlling \u003Cstrong\u003Esecurity behavior\u003C/strong\u003E, rather than merely adjusting a numeric value.\u003C/p\u003E\n\u003Cp\u003EThe client does not decide which sessions remain valid after a credential change.\u003C/p\u003E\n\u003Cp\u003EThat decision belongs to the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-endpoint-exposure\u0022\u003E11. Configure Endpoint Exposure\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows server applications to configure endpoint groups.\u003C/p\u003E\n\u003Cp\u003EThe relevant options are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Endpoints\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/uauth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/uauth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server controls which endpoints are registered and exposed.\u003C/p\u003E\n\u003Cp\u003EThe client controls where it sends requests.\u003C/p\u003E\n\u003Cp\u003EChanging a client endpoint path does not create or enable a server endpoint.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E and \u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-configuration\u0022\u003E12. Multi-Tenant Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports tenant-aware authentication and authorization.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s multi-tenant configuration is available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.MultiTenant\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server uses this configuration to determine how tenant information is resolved.\u003C/p\u003E\n\u003Cp\u003ESupported resolution mechanisms include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERoute-based resolution.\u003C/li\u003E\n\u003Cli\u003EHeader-based resolution.\u003C/li\u003E\n\u003Cli\u003EHost-based resolution.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe corresponding server options include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.MultiTenant.EnableRoute;\noptions.MultiTenant.EnableHeader;\noptions.MultiTenant.EnableDomain;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.EnableHeader = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client has its own multi-tenant options for propagating tenant information to the server.\u003C/p\u003E\n\u003Cp\u003EClient-side tenant selection does not authorize cross-tenant access.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant resolution, validation, and isolation.\u003C/p\u003E\n\u003Cp\u003EFor more details, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-configuration\u0022\u003E13. Resource API Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth also supports a separate Resource API integration.\u003C/p\u003E\n\u003Cp\u003EResource APIs use their own configuration model:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResourceApiOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister a Resource API with:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://auth.example.com\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration section is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:ResourceApi\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;ResourceApi\u0026quot;: {\n \u0026quot;UAuthHubBaseUrl\u0026quot;: \u0026quot;https://auth.example.com\u0026quot;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA Resource API does not become a full UltimateAuth authentication server merely by registering this integration.\u003C/p\u003E\n\u003Cp\u003EIts role is to protect application resources using the configured UltimateAuth authentication infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-resource-api-cors-configuration\u0022\u003EImportant: Resource API CORS Configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API registration constructs its CORS policy using the values supplied to the registration delegate.\u003C/p\u003E\n\u003Cp\u003ETherefore, when configuring \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E, prefer supplying them directly in the registration delegate rather than assuming values bound only from \u003Ccode\u003Eappsettings.json\u003C/code\u003E will automatically populate that CORS policy.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://auth.example.com\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EKeep CORS configuration explicit and restrict allowed origins to trusted applications.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-safe-configuration\u0022\u003E14. Validation and Safe Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core options validation to reject invalid configurations.\u003C/p\u003E\n\u003Cp\u003EThe server registers validators for areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EUser identifiers.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, the server validates that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe endpoint base path is specified.\u003C/li\u003E\n\u003Cli\u003EThe base path does not contain invalid double slashes.\u003C/li\u003E\n\u003Cli\u003EAllowed authentication modes are valid.\u003C/li\u003E\n\u003Cli\u003EExplicitly unsupported modes are not enabled.\u003C/li\u003E\n\u003Cli\u003ESession lifetimes satisfy applicable constraints.\u003C/li\u003E\n\u003Cli\u003EAccess token lifetime does not exceed the configured session maximum lifetime.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configuration is rejected when the applicable session lifetime validation runs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-endpoint-path\u0022\u003EExample: Invalid Endpoint Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//invalid\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configuration fails server options validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-timing\u0022\u003EValidation Timing\u003C/h3\u003E\n\u003Cp\u003EServer options use \u003Ccode\u003EValidateOnStart()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis allows configuration errors to surface during application startup instead of waiting until the first authentication request.\u003C/p\u003E\n\u003Cp\u003EClient options also have registered validators and post-configuration processing, but their validation timing follows the client options registration and resolution lifecycle.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EValidation is an important safeguard, not a guarantee that every logically undesirable configuration is automatically detected.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EApplications must still choose appropriate production settings and securely implement custom extensions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extensibility-through-dependency-injection\u0022\u003E15. Extensibility Through Dependency Injection\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses dependency injection to support customization of selected runtime behaviors.\u003C/p\u003E\n\u003Cp\u003EConfiguration controls the behavior of existing components.\u003C/p\u003E\n\u003Cp\u003EDependency injection allows applications to supply implementations for supported extension points.\u003C/p\u003E\n\u003Cp\u003EExamples include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EExtension point\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeliver credential recovery proofs\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIUserIdentifierVerifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidate identifier ownership proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EITenantIdResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustomize tenant identification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIClock\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProvide a clock implementation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIJwtSigningKeyProvider\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESupply JWT signing keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example, a credential reset notifier can be registered as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E represents an implementation supplied by your application.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s supported registration and resolution behavior must be respected when replacing services.\u003C/p\u003E\n\u003Cp\u003ENot every internal service is intended to be replaced, and custom implementations must preserve UltimateAuth\u0027s security invariants.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-versus-extension\u0022\u003EConfiguration Versus Extension\u003C/h3\u003E\n\u003Cp\u003EUse configuration when you want to adjust a supported behavior:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.Lifetime = TimeSpan.FromHours(8);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse an extension point when your application needs to provide its own implementation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation helps keep ordinary configuration simple while allowing advanced integration where necessary.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-customization\u0022\u003E16. Endpoint Customization\u003C/h2\u003E\n\u003Cp\u003EThe server exposes an advanced endpoint configuration hook:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.OnConfigureEndpoints\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe hook receives an \u003Ccode\u003EIEndpointRouteBuilder\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIt is intended for advanced endpoint configuration after UltimateAuth registers its default endpoints.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may use the hook to apply additional endpoint metadata or filters where appropriate.\u003C/p\u003E\n\u003Cp\u003EHowever, it must not remove or remap UltimateAuth endpoints in ways that invalidate expected security behavior.\u003C/p\u003E\n\u003Cp\u003EEndpoint customization should be treated as an advanced integration mechanism, not a replacement for the supported endpoint configuration options.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended-configuration-strategy\u0022\u003E17. Recommended Configuration Strategy\u003C/h2\u003E\n\u003Cp\u003EFor most applications, use the following approach.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-server-security-settings-on-the-server\u0022\u003EKeep Server Security Settings on the Server\u003C/h3\u003E\n\u003Cp\u003EConfigure authentication modes, token lifetimes, session lifetimes, credential policies, and tenant resolution through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-client-behavior-in-client-options\u0022\u003EKeep Client Behavior in Client Options\u003C/h3\u003E\n\u003Cp\u003EConfigure client profiles, navigation, auto-refresh, reauthentication, and endpoint usage through \u003Ccode\u003EUAuthClientOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-configuration-files-for-environment-differences\u0022\u003EUse Configuration Files for Environment Differences\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003Eappsettings.json\u003C/code\u003E, environment-specific configuration, and environment variables for values that differ between development, staging, and production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022prefer-strongly-typed-configuration\u0022\u003EPrefer Strongly Typed Configuration\u003C/h3\u003E\n\u003Cp\u003EUse the provided option types rather than inventing custom configuration keys for features already supported by UltimateAuth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-secure-defaults\u0022\u003EPreserve Secure Defaults\u003C/h3\u003E\n\u003Cp\u003EOnly change security-sensitive defaults when the application\u0027s requirements justify the change.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022avoid-duplicate-core-configuration\u0022\u003EAvoid Duplicate Core Configuration\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, do not separately configure Core options and expect them to override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validate-production-configuration\u0022\u003EValidate Production Configuration\u003C/h3\u003E\n\u003Cp\u003ETest startup validation and authentication flows using the effective configuration that will be deployed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-reference\u0022\u003E18. Configuration Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication role\u003C/th\u003E\n\u003Cth\u003ERegistration method\u003C/th\u003E\n\u003Cth\u003EOptions type\u003C/th\u003E\n\u003Cth\u003EConfiguration section\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthentication server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUltimateAuth client\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthClient()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClientOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Client\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResource API\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthResourceApiOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:ResourceApi\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECore-only infrastructure\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Core\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor normal server applications, \u003Cstrong\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the effective configuration entry point for both Core-defined authentication settings and server-specific behavior\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe Core-only registration exists for infrastructure scenarios that explicitly consume \u003Ccode\u003EUAuthOptions\u003C/code\u003E; it should not be treated as an additional server configuration layer.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration-overview\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the overall configuration model.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/core-options\u0022\u003ECore Options\u003C/a\u003E \u2014 Explore the reusable option definitions composed into server configuration.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, flows, and SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Customize integration, multi-tenancy, and runtime extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a structured configuration system for controlling authentication behavior, session lifecycles, token policies, client integration, and server security.\u003C/p\u003E\n\u003Cp\u003EConfiguration is organized around the application components that actually consume it.\u003C/p\u003E\n\u003Cp\u003EFor most applications, the two primary configuration types are:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E\u003C/strong\u003E \u2014 Controls authentication and security behavior on the UltimateAuth server.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EUAuthClientOptions\u003C/code\u003E\u003C/strong\u003E \u2014 Controls how an application client communicates with UltimateAuth and executes authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth also defines reusable options in its Core package. These types form the foundation of server configuration, but they do not need to be configured independently when using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis guide explains where configuration belongs, how options are registered, how configuration sources interact, and how to customize behavior safely.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-architecture\u0022\u003E1. Configuration Architecture\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates reusable option definitions from the runtime components that consume them.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth\n\u2502\n\u251C\u2500\u2500 Core\n\u2502 \u2514\u2500\u2500 Shared option definitions\n\u2502 \u251C\u2500\u2500 Login\n\u2502 \u251C\u2500\u2500 Session\n\u2502 \u251C\u2500\u2500 Token\n\u2502 \u251C\u2500\u2500 PKCE\n\u2502 \u2514\u2500\u2500 Multi-tenancy\n\u2502\n\u251C\u2500\u2500 Server\n\u2502 \u2514\u2500\u2500 UAuthServerOptions\n\u2502 \u251C\u2500\u2500 Core-defined options\n\u2502 \u251C\u2500\u2500 Authentication mode restrictions\n\u2502 \u251C\u2500\u2500 Cookie and session resolution\n\u2502 \u251C\u2500\u2500 Endpoints\n\u2502 \u251C\u2500\u2500 Hub configuration\n\u2502 \u251C\u2500\u2500 Credential policies\n\u2502 \u2514\u2500\u2500 Server-specific behavior\n\u2502\n\u2514\u2500\u2500 Client\n \u2514\u2500\u2500 UAuthClientOptions\n \u251C\u2500\u2500 Client profile\n \u251C\u2500\u2500 Login and PKCE flows\n \u251C\u2500\u2500 Endpoint configuration\n \u251C\u2500\u2500 Auto-refresh\n \u251C\u2500\u2500 Reauthentication\n \u2514\u2500\u2500 Client state events\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe important distinction is between \u003Cstrong\u003Ewhere an option type is defined\u003C/strong\u003E and \u003Cstrong\u003Ewhere its value is consumed\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E is defined in Core, but a normal UltimateAuth Server application configures it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server consumes \u003Ccode\u003Eoptions.Session\u003C/code\u003E from \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThere is no need to configure a separate Core options instance for this setting.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-server\u0022\u003E2. Configure the Server\u003C/h2\u003E\n\u003Cp\u003EThe main server registration method is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Server configuration.\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis registers the UltimateAuth server infrastructure and its configuration.\u003C/p\u003E\n\u003Cp\u003EThe method accepts an optional configuration delegate:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EAction\u0026lt;UAuthServerOptions\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIt also supports a separate optional delegate for registering access policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-authentication-and-session-settings\u0022\u003EExample: Authentication and Session Settings\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n\n options.Session.Lifetime = TimeSpan.FromHours(8);\n\n options.Token.AccessTokenLifetime =\n TimeSpan.FromMinutes(15);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control different parts of the authentication lifecycle:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin.MaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum failed login attempts under the configured login policy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession.Lifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken.AccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its validation rules to the resulting configuration.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EConfiguration values should be chosen together.\u003C/strong\u003E For example, token lifetime settings must remain compatible with the configured session lifetime limits.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-options-are-composed-into-server-options\u0022\u003E3. Core Options Are Composed into Server Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E includes the following Core-defined option groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic UAuthLoginOptions Login { get; set; } = new();\n\npublic UAuthSessionOptions Session { get; set; } = new();\n\npublic UAuthTokenOptions Token { get; set; } = new();\n\npublic UAuthPkceOptions Pkce { get; set; } = new();\n\npublic UAuthMultiTenantOptions MultiTenant { get; set; } = new();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is composition, not duplication.\u003C/p\u003E\n\u003Cp\u003EThe Core package owns the shared option definitions, while the Server package uses those definitions as part of its effective server configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended\u0022\u003ERecommended\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n options.Pkce.Enabled = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022not-recommended-for-server-configuration\u0022\u003ENot Recommended for Server Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuth(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\nbuilder.Services.AddUltimateAuthServer();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe second example configures \u003Ccode\u003EUAuthOptions\u003C/code\u003E, not the \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance consumed by the server\u0027s authentication runtime.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhen using UltimateAuth Server, configure Core-defined behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E or the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EDirect Core configuration is intended for scenarios that explicitly consume \u003Ccode\u003EUAuthOptions\u003C/code\u003E rather than the server configuration model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-configuration-groups\u0022\u003E4. Server Configuration Groups\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E organizes server behavior into focused option groups.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and authentication behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance, lifetimes, and refresh behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE flow configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution and multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestricts permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment arrangement\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub-specific configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication cookie behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolving session identifiers from incoming requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer endpoint configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession handling after credential changes and recovery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential reset configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential policy\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types permitted for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination limits and defaults\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication response behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENavigation and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDiagnostic configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer event configuration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese groups allow applications to customize specific behavior without replacing the overall authentication architecture.\u003C/p\u003E\n\u003Cp\u003EThe individual option types and their defaults are documented in \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-client\u0022\u003E5. Configure the Client\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients have their own configuration model.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoDetectClientProfile = true;\n options.DefaultReturnUrl = \u0026quot;/\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe registration accepts:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EAction\u0026lt;UAuthClientOptions\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client options control how the client behaves, not the server\u0027s security policy.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoRefresh.Enabled = true;\n options.DefaultReturnUrl = \u0026quot;/dashboard\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEnabling client auto-refresh does not change the server\u0027s refresh token lifetime, session expiration rules, or rotation policy.\u003C/p\u003E\n\u003Cp\u003EThose are server-side responsibilities.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-configuration-groups\u0022\u003EClient Configuration Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExplicit client profile\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAutoDetectClientProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAutomatic client profile detection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultReturnUrl\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDefault post-authentication navigation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient login flow behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient PKCE flow behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAutoRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAutomatic refresh behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReauth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReauthentication behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EServer endpoint paths used by the client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient tenant propagation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EStateEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient state event handling\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E for the full reference.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-and-client-configuration-are-independent\u0022\u003E6. Server and Client Configuration Are Independent\u003C/h2\u003E\n\u003Cp\u003EA project may register both UltimateAuth Server and UltimateAuth Client.\u003C/p\u003E\n\u003Cp\u003EThis is common in applications that host authentication services and also use the client SDK.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\nbuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.AutoDetectClientProfile = true;\n options.DefaultReturnUrl = \u0026quot;/\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese registrations configure different components.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ESetting\u003C/th\u003E\n\u003Cth\u003EServer\u003C/th\u003E\n\u003Cth\u003EClient\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession lifetime\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EToken lifetime\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential reset policy\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAllowed authentication modes\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient profile\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient auto-refresh\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EClient navigation\u003C/td\u003E\n\u003Ctd\u003EDoes not control\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServer endpoint exposure\u003C/td\u003E\n\u003Ctd\u003EControls\u003C/td\u003E\n\u003Ctd\u003EUses configured routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe client must be compatible with the server\u0027s configuration, but it cannot override server-side security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, enabling PKCE in client options does not make a disabled or unsupported PKCE server flow available.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-sources\u0022\u003E7. Configuration Sources\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports configuration through registration delegates and the standard ASP.NET Core configuration system.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022code-based-configuration\u0022\u003ECode-Based Configuration\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EProgram.cs\u003C/code\u003E for explicit configuration.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when configuration is closely tied to application behavior or requires strongly typed values.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-based-setup\u0022\u003EConfiguration-Based Setup\u003C/h3\u003E\n\u003Cp\u003EThe server binds settings from:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example, in \u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;08:00:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client binds settings from:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Client\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Client\u0026quot;: {\n \u0026quot;AutoDetectClientProfile\u0026quot;: true,\n \u0026quot;DefaultReturnUrl\u0026quot;: \u0026quot;/dashboard\u0026quot;,\n \u0026quot;AutoRefresh\u0026quot;: {\n \u0026quot;Enabled\u0026quot;: true\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese sections are independent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-both-sources\u0022\u003EUsing Both Sources\u003C/h3\u003E\n\u003Cp\u003EYou can combine code-based and configuration-based settings:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromHours(8);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 3\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003ELogin.MaxFailedAttempts\u003C/code\u003E is \u003Ccode\u003E3\u003C/code\u003E, because the registration binds the configuration section after applying the configuration delegate.\u003C/p\u003E\n\u003Cp\u003EThe session lifetime remains eight hours because the configuration section does not override it.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-precedence\u0022\u003E8. Configuration Precedence\u003C/h2\u003E\n\u003Cp\u003EThe current registration pipeline applies options in this order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EOption type defaults.\u003C/li\u003E\n\u003Cli\u003EValues supplied by the registration delegate.\u003C/li\u003E\n\u003Cli\u003EValues bound from the corresponding configuration section.\u003C/li\u003E\n\u003Cli\u003EApplicable post-configuration processing.\u003C/li\u003E\n\u003Cli\u003EOptions validation.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor the server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eservices.AddOptions\u0026lt;UAuthServerOptions\u0026gt;()\n .Configure(options =\u0026gt;\n {\n configure?.Invoke(options);\n })\n .BindConfiguration(\u0026quot;UltimateAuth:Server\u0026quot;)\n .ValidateOnStart();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor the client, the corresponding configuration section is \u003Ccode\u003EUltimateAuth:Client\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-distinction\u0022\u003EImportant Distinction\u003C/h3\u003E\n\u003Cp\u003EWhen we say configuration values override \u003Ccode\u003EProgram.cs\u003C/code\u003E, we mean that the \u003Cstrong\u003Elater configuration binding takes precedence for the same property\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe exact value also depends on the normal ASP.NET Core configuration provider order.\u003C/p\u003E\n\u003Cp\u003EFor example, environment-specific configuration files and environment variables may override values from the base \u003Ccode\u003Eappsettings.json\u003C/code\u003E file.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022environment-variable-example\u0022\u003EEnvironment Variable Example\u003C/h3\u003E\n\u003Cp\u003EASP.NET Core configuration supports double underscores as section separators.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth__Server__Login__MaxFailedAttempts=3\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis maps to:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server:Login:MaxFailedAttempts\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a client setting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth__Client__DefaultReturnUrl=/dashboard\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis makes it possible to provide environment-specific settings without modifying application code.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-mode-configuration\u0022\u003E9. Authentication Mode Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports multiple authentication mode concepts.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting defines which modes the server is permitted to use.\u003C/p\u003E\n\u003Cp\u003EIt is a \u003Cstrong\u003Erestriction\u003C/strong\u003E, not a direct mode selector.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved by the server\u0027s authentication mode resolution mechanism.\u003C/p\u003E\n\u003Cp\u003ESetting \u003Ccode\u003EAllowedModes\u003C/code\u003E does not directly choose which mode every client will use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cp\u003EIn the current implementation, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled server modes.\u003C/p\u003E\n\u003Cp\u003EThe server options validator rejects them when explicitly included in \u003Ccode\u003EAllowedModes\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, this configuration is invalid:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not enable an unsupported mode merely because it exists in the enum.\u003C/p\u003E\n\u003Cp\u003EFor supported modes and their behavior, see \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-credential-session-policies\u0022\u003E10. Configure Credential Session Policies\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports configurable session invalidation after credential changes.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange =\n CredentialSessionBehavior.PreserveCurrent;\n\n options.CredentialSessionPolicy.OnAdminChange =\n CredentialSessionBehavior.RevokeAll;\n\n options.CredentialSessionPolicy.OnRecovery =\n CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe available behaviors are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current chain and revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an example of configuration controlling \u003Cstrong\u003Esecurity behavior\u003C/strong\u003E, rather than merely adjusting a numeric value.\u003C/p\u003E\n\u003Cp\u003EThe client does not decide which sessions remain valid after a credential change.\u003C/p\u003E\n\u003Cp\u003EThat decision belongs to the server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-endpoint-exposure\u0022\u003E11. Configure Endpoint Exposure\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth allows server applications to configure endpoint groups.\u003C/p\u003E\n\u003Cp\u003EThe relevant options are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Endpoints\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/uauth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClient(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/uauth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server controls which endpoints are registered and exposed.\u003C/p\u003E\n\u003Cp\u003EThe client controls where it sends requests.\u003C/p\u003E\n\u003Cp\u003EChanging a client endpoint path does not create or enable a server endpoint.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E and \u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-configuration\u0022\u003E12. Multi-Tenant Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports tenant-aware authentication and authorization.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s multi-tenant configuration is available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.MultiTenant\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server uses this configuration to determine how tenant information is resolved.\u003C/p\u003E\n\u003Cp\u003ESupported resolution mechanisms include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERoute-based resolution.\u003C/li\u003E\n\u003Cli\u003EHeader-based resolution.\u003C/li\u003E\n\u003Cli\u003EHost-based resolution.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe corresponding server options include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.MultiTenant.EnableRoute;\noptions.MultiTenant.EnableHeader;\noptions.MultiTenant.EnableDomain;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.EnableHeader = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client has its own multi-tenant options for propagating tenant information to the server.\u003C/p\u003E\n\u003Cp\u003EClient-side tenant selection does not authorize cross-tenant access.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant resolution, validation, and isolation.\u003C/p\u003E\n\u003Cp\u003EFor more details, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-configuration\u0022\u003E13. Resource API Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth also supports a separate Resource API integration.\u003C/p\u003E\n\u003Cp\u003EResource APIs use their own configuration model:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResourceApiOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister a Resource API with:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://auth.example.com\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration section is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:ResourceApi\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;ResourceApi\u0026quot;: {\n \u0026quot;UAuthHubBaseUrl\u0026quot;: \u0026quot;https://auth.example.com\u0026quot;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA Resource API does not become a full UltimateAuth authentication server merely by registering this integration.\u003C/p\u003E\n\u003Cp\u003EIts role is to protect application resources using the configured UltimateAuth authentication infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-resource-api-cors-configuration\u0022\u003EImportant: Resource API CORS Configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API registration constructs its CORS policy using the values supplied to the registration delegate.\u003C/p\u003E\n\u003Cp\u003ETherefore, when configuring \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E, prefer supplying them directly in the registration delegate rather than assuming values bound only from \u003Ccode\u003Eappsettings.json\u003C/code\u003E will automatically populate that CORS policy.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://auth.example.com\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EKeep CORS configuration explicit and restrict allowed origins to trusted applications.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-safe-configuration\u0022\u003E14. Validation and Safe Configuration\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core options validation to reject invalid configurations.\u003C/p\u003E\n\u003Cp\u003EThe server registers validators for areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EUser identifiers.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, the server validates that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe endpoint base path is specified.\u003C/li\u003E\n\u003Cli\u003EThe base path does not contain invalid double slashes.\u003C/li\u003E\n\u003Cli\u003EAllowed authentication modes are valid.\u003C/li\u003E\n\u003Cli\u003EExplicitly unsupported modes are not enabled.\u003C/li\u003E\n\u003Cli\u003ESession lifetimes satisfy applicable constraints.\u003C/li\u003E\n\u003Cli\u003EAccess token lifetime does not exceed the configured session maximum lifetime.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configuration is rejected when the applicable session lifetime validation runs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-endpoint-path\u0022\u003EExample: Invalid Endpoint Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//invalid\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configuration fails server options validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-timing\u0022\u003EValidation Timing\u003C/h3\u003E\n\u003Cp\u003EServer options use \u003Ccode\u003EValidateOnStart()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis allows configuration errors to surface during application startup instead of waiting until the first authentication request.\u003C/p\u003E\n\u003Cp\u003EClient options also have registered validators and post-configuration processing, but their validation timing follows the client options registration and resolution lifecycle.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EValidation is an important safeguard, not a guarantee that every logically undesirable configuration is automatically detected.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EApplications must still choose appropriate production settings and securely implement custom extensions.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extensibility-through-dependency-injection\u0022\u003E15. Extensibility Through Dependency Injection\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses dependency injection to support customization of selected runtime behaviors.\u003C/p\u003E\n\u003Cp\u003EConfiguration controls the behavior of existing components.\u003C/p\u003E\n\u003Cp\u003EDependency injection allows applications to supply implementations for supported extension points.\u003C/p\u003E\n\u003Cp\u003EExamples include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EExtension point\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDeliver credential recovery proofs\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIUserIdentifierVerifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EValidate identifier ownership proof\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EITenantIdResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECustomize tenant identification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIClock\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EProvide a clock implementation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIJwtSigningKeyProvider\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESupply JWT signing keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example, a credential reset notifier can be registered as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EApplicationCredentialResetNotifier\u003C/code\u003E represents an implementation supplied by your application.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s supported registration and resolution behavior must be respected when replacing services.\u003C/p\u003E\n\u003Cp\u003ENot every internal service is intended to be replaced, and custom implementations must preserve UltimateAuth\u0027s security invariants.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-versus-extension\u0022\u003EConfiguration Versus Extension\u003C/h3\u003E\n\u003Cp\u003EUse configuration when you want to adjust a supported behavior:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.Lifetime = TimeSpan.FromHours(8);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse an extension point when your application needs to provide its own implementation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ICredentialResetNotifier, ApplicationCredentialResetNotifier\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation helps keep ordinary configuration simple while allowing advanced integration where necessary.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-customization\u0022\u003E16. Endpoint Customization\u003C/h2\u003E\n\u003Cp\u003EThe server exposes an advanced endpoint configuration hook:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.OnConfigureEndpoints\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe hook receives an \u003Ccode\u003EIEndpointRouteBuilder\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIt is intended for advanced endpoint configuration after UltimateAuth registers its default endpoints.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may use the hook to apply additional endpoint metadata or filters where appropriate.\u003C/p\u003E\n\u003Cp\u003EHowever, it must not remove or remap UltimateAuth endpoints in ways that invalidate expected security behavior.\u003C/p\u003E\n\u003Cp\u003EEndpoint customization should be treated as an advanced integration mechanism, not a replacement for the supported endpoint configuration options.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended-configuration-strategy\u0022\u003E17. Recommended Configuration Strategy\u003C/h2\u003E\n\u003Cp\u003EFor most applications, use the following approach.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-server-security-settings-on-the-server\u0022\u003EKeep Server Security Settings on the Server\u003C/h3\u003E\n\u003Cp\u003EConfigure authentication modes, token lifetimes, session lifetimes, credential policies, and tenant resolution through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022keep-client-behavior-in-client-options\u0022\u003EKeep Client Behavior in Client Options\u003C/h3\u003E\n\u003Cp\u003EConfigure client profiles, navigation, auto-refresh, reauthentication, and endpoint usage through \u003Ccode\u003EUAuthClientOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-configuration-files-for-environment-differences\u0022\u003EUse Configuration Files for Environment Differences\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003Eappsettings.json\u003C/code\u003E, environment-specific configuration, and environment variables for values that differ between development, staging, and production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022prefer-strongly-typed-configuration\u0022\u003EPrefer Strongly Typed Configuration\u003C/h3\u003E\n\u003Cp\u003EUse the provided option types rather than inventing custom configuration keys for features already supported by UltimateAuth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022preserve-secure-defaults\u0022\u003EPreserve Secure Defaults\u003C/h3\u003E\n\u003Cp\u003EOnly change security-sensitive defaults when the application\u0027s requirements justify the change.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022avoid-duplicate-core-configuration\u0022\u003EAvoid Duplicate Core Configuration\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, do not separately configure Core options and expect them to override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validate-production-configuration\u0022\u003EValidate Production Configuration\u003C/h3\u003E\n\u003Cp\u003ETest startup validation and authentication flows using the effective configuration that will be deployed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-reference\u0022\u003E18. Configuration Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication role\u003C/th\u003E\n\u003Cth\u003ERegistration method\u003C/th\u003E\n\u003Cth\u003EOptions type\u003C/th\u003E\n\u003Cth\u003EConfiguration section\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthentication server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUltimateAuth client\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthClient()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClientOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Client\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResource API\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthResourceApiOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:ResourceApi\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECore-only infrastructure\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUltimateAuth:Core\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor normal server applications, \u003Cstrong\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the effective configuration entry point for both Core-defined authentication settings and server-specific behavior\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe Core-only registration exists for infrastructure scenarios that explicitly consume \u003Ccode\u003EUAuthOptions\u003C/code\u003E; it should not be treated as an additional server configuration layer.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration-overview\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the overall configuration model.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, flows, and SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Customize integration, multi-tenancy, and runtime extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "configuration-architecture", @@ -214,6 +214,6 @@ "Level": 0 } ], - "SourceHash": "42602ECDDEC509FEAAEF5F7835FCE10B05B3FCAC730541040EF2E96C21180A86", + "SourceHash": "A7FB3BC604C292181E8C5201DE6235C0020C825876F238F5265F38E4A23A383B", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json index 399d4a59..841d876f 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json @@ -1,7 +1,7 @@ { "Slug": "configuration/server-options", "Title": "Server Options", - "Html": "\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the primary configuration surface for UltimateAuth Server.\u003C/p\u003E\n\u003Cp\u003EIt controls authentication behavior, session lifecycles, token issuance, credential security, tenant resolution, endpoint exposure, and integration with UAuthHub.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth composes its Core-defined options directly into \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E. When using UltimateAuth Server, you configure both shared authentication behavior and server-specific features through the same options object.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-ultimateauth-server\u0022\u003E1. Register UltimateAuth Server\u003C/h2\u003E\n\u003Cp\u003EConfigure the server through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration delegate receives:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server registration also supports an optional policy configuration delegate.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(\n configure: options =\u0026gt;\n {\n options.Login.MaxFailedAttempts = 5;\n },\n configurePolicies: policies =\u0026gt;\n {\n // Register application access policies.\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server automatically registers its Core infrastructure. You do not need to call \u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E separately.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-from-appsettings.json\u0022\u003EConfiguration from appsettings.json\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth Server binds the configuration section:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;7.00:00:00\u0026quot;\n },\n \u0026quot;Token\u0026quot;: {\n \u0026quot;AccessTokenLifetime\u0026quot;: \u0026quot;00:10:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EASP.NET Core represents \u003Ccode\u003ETimeSpan\u003C/code\u003E values using strings such as \u003Ccode\u003E00:15:00\u003C/code\u003E for fifteen minutes and \u003Ccode\u003E7.00:00:00\u003C/code\u003E for seven days.\u003C/p\u003E\n\u003Cp\u003EThe configuration section is bound after the registration delegate, so configuration provider values take precedence for matching properties.\u003C/p\u003E\n\u003Cp\u003EFor configuration source ordering and environment-specific overrides, see \u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-architecture\u0022\u003E2. Server Options Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E contains two categories of configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-defined-options\u0022\u003ECore-defined options\u003C/h3\u003E\n\u003Cp\u003EThese types are defined in UltimateAuth Core and composed into the server options:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthTokenOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh token behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPkceOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE authorization code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthMultiTenantOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant identification and resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPaginationOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination defaults and limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-specific-options\u0022\u003EServer-specific options\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestrict permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribe the relationship between UAuthHub and the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration and flow settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie names, security attributes, and lifetime behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession identifier sources and precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint groups and disabled actions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential selection for UI and API requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication credential delivery and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery code and token settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior, normalization, and uniqueness\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername, email, and phone validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types allowed for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile capabilities\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication navigation behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional diagnostic response details\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdvanced configuration hooks are also available, including \u003Ccode\u003EConfigureMode()\u003C/code\u003E and \u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-options\u0022\u003E3. Login Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Login\u003C/code\u003E controls login attempt limits, lockout behavior, and related security settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.FailureWindow = TimeSpan.FromMinutes(15);\n options.Login.ExtendLockOnFailure = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFailed login threshold; \u003Ccode\u003E0\u003C/code\u003E disables lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003ELockout duration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003EWindow for counting failed attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExtendLockOnFailure\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExtend lockout when another attempt fails during lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeFailureDetails\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude detailed authentication failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E180 seconds\u003C/td\u003E\n\u003Ctd\u003EDuration associated with the try-login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restrict-login-failure-details\u0022\u003EExample: Restrict Login Failure Details\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.IncludeFailureDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is useful when an application does not want to expose detailed lockout information to callers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important\u0022\u003EImportant\u003C/h3\u003E\n\u003Cp\u003ELockout configuration is not equivalent to complete network-level rate limiting.\u003C/p\u003E\n\u003Cp\u003EProduction deployments should also consider infrastructure protections against abusive traffic and distributed login attempts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-options\u0022\u003E4. Session Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Session\u003C/code\u003E controls session lifetime, expiration, sliding behavior, and session-chain limits.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.SlidingExpiration = true;\n options.Session.IdleTimeout = TimeSpan.FromDays(3);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-lifetime-properties\u0022\u003ESession Lifetime Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003EStandard session lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional absolute lifetime limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESlidingExpiration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAllow eligible activity or refresh operations to extend expiration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional inactivity timeout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003EMinimum interval between last-seen updates\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings have different purposes.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ELifetime\u003C/code\u003E determines the standard lifetime assigned to a session.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E establishes an optional hard boundary that cannot be extended indefinitely.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E limits how long a session can remain inactive.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E reduces unnecessary persistence operations caused by frequent activity updates.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chain-properties\u0022\u003ESession-Chain Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved user-level chain limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxSessionsPerChain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E100\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession-chain rotation limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatformCategories\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform group definitions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerCategory\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved category-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableIpBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved IP binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableUserAgentBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved User-Agent binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceMismatchBehavior\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EReject\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved device mismatch behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation limitation:\u003C/strong\u003E The source identifies several device limits and binding settings as reserved for future enforcement. Setting these properties must not be treated as proof that the corresponding protection is active.\u003C/p\u003E\n\u003Cp\u003EFor example, do not rely on:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.MaxChainsPerUser = 1;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eas a production guarantee that only one device chain can exist unless the current runtime implementation has been verified to enforce that restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-specific-session-defaults\u0022\u003EMode-Specific Session Defaults\u003C/h3\u003E\n\u003Cp\u003ESession settings can also be affected by authentication mode defaults.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E supplies a longer idle timeout and touch interval when those values have not been configured.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EHybrid\u003C/code\u003E explicitly sets \u003Ccode\u003ETouchInterval\u003C/code\u003E to \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for the effective-options resolution order.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-options\u0022\u003E5. Token Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Token\u003C/code\u003E configures access and refresh token behavior.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-1\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueJwt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable JWT issuance where supported by the effective mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueOpaque\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable opaque token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable refresh token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003ERefresh token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOpaqueIdBytes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E32\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERandom byte length used for opaque token identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenConcurrentRequestWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E2 seconds\u003C/td\u003E\n\u003Ctd\u003EConcurrent duplicate refresh classification window\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssuer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT issuer\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAudience\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT audience\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddJwtIdClaim\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude a JWT identifier claim\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeyId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional signing key identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-token-concurrency-window\u0022\u003ERefresh Token Concurrency Window\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromSeconds(2);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls how the server distinguishes a closely timed duplicate refresh request from a confirmed replay.\u003C/p\u003E\n\u003Cp\u003EA duplicate refresh request is still rejected.\u003C/p\u003E\n\u003Cp\u003EThe window affects the replay-response classification, including whether the token family should be revoked.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E allow the same consumed refresh token to be used successfully more than once.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-defaults-may-override-token-values\u0022\u003EMode Defaults May Override Token Values\u003C/h3\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003EHybrid\u003C/code\u003E configuration explicitly sets:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EToken.AccessTokenLifetime = TimeSpan.FromMinutes(10);\nToken.RefreshTokenLifetime = TimeSpan.FromDays(7);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETherefore, a different value configured globally may be replaced when the effective mode is resolved.\u003C/p\u003E\n\u003Cp\u003ETo customize the lifetime for \u003Ccode\u003EHybrid\u003C/code\u003E, use:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis distinction is essential when configuring token lifetimes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-options\u0022\u003E6. PKCE Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pkce\u003C/code\u003E configures the lifetime of PKCE authorization codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCodeLifetimeSeconds\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E120\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELifetime of a PKCE authorization code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is particularly important for browser-based clients such as Blazor WebAssembly.\u003C/p\u003E\n\u003Cp\u003EA shorter authorization code lifetime reduces the period during which an intercepted or exposed code could potentially be used.\u003C/p\u003E\n\u003Cp\u003EHowever, the value must remain long enough for legitimate authorization exchanges to complete.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s PKCE configuration is separate from the client\u0027s PKCE flow settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003E7. Authentication Modes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports authentication mode resolution based on the client profile.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property restricts the modes the server is permitted to use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction, not a mode selector.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved by \u003Ccode\u003EIEffectiveAuthModeResolver\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-mode-resolution\u0022\u003EDefault Mode Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EWebServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorWasm\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaui\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation has mode templates for \u003Ccode\u003EPureOpaque\u003C/code\u003E, \u003Ccode\u003EHybrid\u003C/code\u003E, \u003Ccode\u003ESemiHybrid\u003C/code\u003E, and \u003Ccode\u003EPureJwt\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled modes by the current server options validator.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable an unsupported mode merely because it exists in the enum or has a mode-default template.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EApi\u003C/code\u003E profile mapping to \u003Ccode\u003EPureJwt\u003C/code\u003E is a current compatibility limitation to consider when deploying API-profile authentication flows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-mode-specific-behavior\u0022\u003EConfigure Mode-Specific Behavior\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for behavior that should differ by mode.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(7);\n });\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mode override runs after the built-in mode defaults.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-options\u0022\u003E8. Endpoint Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Endpoints\u003C/code\u003E controls endpoint paths, feature groups, and individual action restrictions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022base-path\u0022\u003EBase Path\u003C/h3\u003E\n\u003Cp\u003EThe default server endpoint base path is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E/auth\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EChanging the base path can help avoid route conflicts when integrating UltimateAuth into an existing application.\u003C/p\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-groups\u0022\u003EEndpoint Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserLifecycle\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser lifecycle endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentials\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorization\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthorization endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Authorization = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDisabling an endpoint group controls endpoint exposure. It does not automatically remove the underlying domain services or invalidate previously issued credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-individual-actions\u0022\u003EDisable Individual Actions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EDisabledActions\u003C/code\u003E is a collection of action identifiers:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.DisabledActions.Add(\u0026quot;users.create.anonymous\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe string must match the action identifier used by the server\u0027s endpoint registration and action policy.\u003C/p\u003E\n\u003Cp\u003EDo not pass a C# constant name as a string unless that exact text is also the registered action value.\u003C/p\u003E\n\u003Cp\u003EFor example, these are not necessarily equivalent:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;UAuthActions.Users.Create.Anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eand:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;users.create.anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the actual action value defined by the corresponding \u003Ccode\u003EUAuthActions\u003C/code\u003E constant.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-hardening\u0022\u003EEndpoint Hardening\u003C/h3\u003E\n\u003Cp\u003EDisable endpoints that are not required by the application.\u003C/p\u003E\n\u003Cp\u003EFor example, an application that delegates user provisioning to administrators may choose to disable anonymous user creation.\u003C/p\u003E\n\u003Cp\u003EEndpoint exposure is a separate concern from authorization: an exposed endpoint must still enforce the applicable security policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-resolution\u0022\u003E9. Session Resolution\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.SessionResolution\u003C/code\u003E controls how incoming requests provide session identifiers.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports four resolution sources:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EBearer credentials.\u003C/li\u003E\n\u003Cli\u003ECustom headers.\u003C/li\u003E\n\u003Cli\u003ECookies.\u003C/li\u003E\n\u003Cli\u003EQuery parameters.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-configuration\u0022\u003EDefault Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableBearer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableQuery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-UAuth-Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryParameterName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Esession_id\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default precedence order is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBearer\nHeader\nCookie\nQuery\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-resolution-sources\u0022\u003EConfigure Resolution Sources\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableHeader = false;\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableQuery = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example disables custom-header and query-string session resolution.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-resolution-order\u0022\u003EChange Resolution Order\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.Order =\n [\n \u0026quot;Cookie\u0026quot;,\n \u0026quot;Bearer\u0026quot;,\n \u0026quot;Header\u0026quot;,\n \u0026quot;Query\u0026quot;\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResolution order matters when a request contains multiple candidate credentials.\u003C/p\u003E\n\u003Cp\u003EThe selected credential must still pass authentication and session validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EAvoid query-string credentials unless the application has a specific, well-understood requirement.\u003C/p\u003E\n\u003Cp\u003EURLs may be recorded in browser history, reverse-proxy logs, analytics systems, and other infrastructure.\u003C/p\u003E\n\u003Cp\u003EFor most browser and API integrations, cookies or authorization headers are preferable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-configuration\u0022\u003E10. Cookie Configuration\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Cookie\u003C/code\u003E controls cookies used for authentication credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth defines three cookie groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Cookie.Session\noptions.Cookie.AccessToken\noptions.Cookie.RefreshToken\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-cookie-names\u0022\u003EDefault Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECookie\u003C/th\u003E\n\u003Cth\u003EDefault name\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euas\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccess token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euat\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euar\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default cookie options use:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EHttpOnly = true\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ESecurePolicy = CookieSecurePolicy.Always\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPath = \u0026quot;/\u0026quot;\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDomain = null\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMaxAge = null\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003ESameSite\u003C/code\u003E is nullable and can be resolved through the applicable cookie and deployment policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-cookie-names\u0022\u003ECustomize Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Name = \u0026quot;myapp_session\u0026quot;;\n options.Cookie.RefreshToken.Name = \u0026quot;myapp_refresh\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-cookie-scope\u0022\u003EConfigure Cookie Scope\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Path = \u0026quot;/\u0026quot;;\n options.Cookie.Session.Domain = null;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA null domain produces a host-only cookie.\u003C/p\u003E\n\u003Cp\u003ESetting a parent domain can broaden cookie availability to subdomains and should be done only when necessary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-security\u0022\u003ECookie Security\u003C/h3\u003E\n\u003Cp\u003EAvoid disabling \u003Ccode\u003EHttpOnly\u003C/code\u003E or relaxing secure transport requirements for authentication cookies.\u003C/p\u003E\n\u003Cp\u003ECookie settings interact with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe deployment mode.\u003C/li\u003E\n\u003Cli\u003ECross-site requests.\u003C/li\u003E\n\u003Cli\u003EBrowser cookie policies.\u003C/li\u003E\n\u003Cli\u003ECredential delivery configuration.\u003C/li\u003E\n\u003Cli\u003EHTTPS requirements.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor cross-site deployments, configure cookie behavior together with CORS and the relevant browser security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-deployment-mode\u0022\u003E11. Hub Deployment Mode\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.HubDeploymentMode\u003C/code\u003E describes how UAuthHub is deployed relative to the application.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-modes\u0022\u003EAvailable Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmbedded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub and application share the same origin\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIntegrated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is separate but within the same-site boundary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExternal\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is hosted on a different site or domain\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthHubDeploymentMode.Integrated\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022embedded\u0022\u003EEmbedded\u003C/h3\u003E\n\u003Cp\u003ESuitable for applications hosting UltimateAuth within the same application and origin.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022integrated\u0022\u003EIntegrated\u003C/h3\u003E\n\u003Cp\u003ESuitable for deployments such as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eauth.example.com\napp.example.com\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Ewhere the authentication service and application are separate origins within the same site.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022external\u0022\u003EExternal\u003C/h3\u003E\n\u003Cp\u003ESuitable for a separate authentication provider domain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.External;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EExternal deployment requires careful coordination of cookie policies, redirects, allowed origins, and browser restrictions.\u003C/p\u003E\n\u003Cp\u003EThe deployment mode influences authentication transport behavior; it is not merely descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-options\u0022\u003E12. Hub Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Hub\u003C/code\u003E configures UAuthHub integration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-2\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientBaseAddress\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional client application base address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty set\u003C/td\u003E\n\u003Ctd\u003EAllowed client origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003ELifetime of Hub flow artifacts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginPath\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E/login\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub login page path\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n options.Hub.FlowLifetime = TimeSpan.FromMinutes(5);\n options.Hub.LoginPath = \u0026quot;/login\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub flow lifetime is not the same as the authenticated session lifetime.\u003C/p\u003E\n\u003Cp\u003EHub artifacts support authentication orchestration and should generally be short-lived.\u003C/p\u003E\n\u003Cp\u003EOnly trusted origins should be included in \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-credential-policy\u0022\u003E13. Primary Credential Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.PrimaryCredential\u003C/code\u003E controls which credential kind is preferred for UI and API requests.\u003C/p\u003E\n\u003Cp\u003EThe defaults are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.AccessToken\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-1\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.PrimaryCredential.Ui = PrimaryTokenKind.Session;\n options.PrimaryCredential.Api = PrimaryTokenKind.AccessToken;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows UltimateAuth to distinguish between session-oriented browser interactions and access-token-oriented API interactions.\u003C/p\u003E\n\u003Cp\u003EThe primary credential policy does not bypass authentication validation.\u003C/p\u003E\n\u003Cp\u003EIt determines which credential kind should be preferred within the applicable request-processing rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-session-policy\u0022\u003E14. Credential Session Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.CredentialSessionPolicy\u003C/code\u003E determines what happens to active session chains after credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-3\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser changes their own credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdministrator changes a user\u0027s credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery completes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-2\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-behaviors\u0022\u003EAvailable Behaviors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EEffect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep the current chain and revoke other applicable chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor recovery flows, \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not always meaningful because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EThe current implementation treats this situation using the more restrictive \u003Ccode\u003ERevokeAll\u003C/code\u003E behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation-1\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EPreserving the current session after a normal password change can provide a reasonable user experience.\u003C/p\u003E\n\u003Cp\u003ERevoking all sessions after administrative credential changes or account recovery is generally more conservative.\u003C/p\u003E\n\u003Cp\u003EChoose the policy according to the application\u0027s security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-reset-options\u0022\u003E15. Credential Reset Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.ResetCredential\u003C/code\u003E configures credential recovery proof lifetimes and attempt limits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022defaults\u0022\u003EDefaults\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETokenValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E30 minutes\u003C/td\u003E\n\u003Ctd\u003EReset token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum permitted recovery attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E6\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code length\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-3\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(20);\n options.ResetCredential.CodeValidity = TimeSpan.FromMinutes(5);\n options.ResetCredential.MaxAttempts = 3;\n options.ResetCredential.CodeLength = 6;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E applies to numeric reset codes, not token-based recovery proofs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-security-boundary\u0022\u003EImportant Security Boundary\u003C/h3\u003E\n\u003Cp\u003EReset proofs must be delivered through a trusted server-side mechanism.\u003C/p\u003E\n\u003Cp\u003EDo not return recovery tokens to anonymous callers simply to simplify a client-side reset form.\u003C/p\u003E\n\u003Cp\u003EThe application should provide the appropriate \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E implementation for supported recovery flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-options\u0022\u003E16. Login Identifier Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.LoginIdentifiers\u003C/code\u003E determines which identifier types can be used for login.\u003C/p\u003E\n\u003Cp\u003EThe default allowed types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUserIdentifierType.Username\nUserIdentifierType.Email\nUserIdentifierType.Phone\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-login-identifier-types\u0022\u003ERestrict Login Identifier Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.AllowedTypes =\n new HashSet\u0026lt;UserIdentifierType\u0026gt;\n {\n UserIdentifierType.Email\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configures email as the allowed login identifier type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022require-verification\u0022\u003ERequire Verification\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n options.LoginIdentifiers.RequireVerificationForPhone = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control whether the corresponding identifiers must be verified before being accepted for login.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-resolvers\u0022\u003ECustom Resolvers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCustomResolvers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECustomResolversFirst\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese options control the participation and ordering of custom login identifier resolvers.\u003C/p\u003E\n\u003Cp\u003EAn identifier existing on a user account does not necessarily mean it is permitted for login.\u003C/p\u003E\n\u003Cp\u003EIdentifier management and login eligibility are separate concerns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-validation\u0022\u003E17. Identifier Validation\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.IdentifierValidation\u003C/code\u003E provides rules for usernames, email addresses, and phone numbers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022username-rules\u0022\u003EUsername Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.IdentifierValidation.UserName.Enabled = true;\n options.IdentifierValidation.UserName.MinLength = 3;\n options.IdentifierValidation.UserName.MaxLength = 32;\n options.IdentifierValidation.UserName.AllowedRegex = \u0026quot;^[a-zA-Z0-9._-]\u002B$\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-validation-rules\u0022\u003EDefault Validation Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EIdentifier\u003C/th\u003E\n\u003Cth\u003EEnabled\u003C/th\u003E\n\u003Cth\u003EMinimum length\u003C/th\u003E\n\u003Cth\u003EMaximum length\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E64\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EEmail\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E256\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPhone\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E24\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUsername validation additionally supports \u003Ccode\u003EAllowedRegex\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default username expression is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EREGEX\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-regex\u0022\u003E^[a-zA-Z0-9._-]\u002B$\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-behavior\u0022\u003EIdentifier Behavior\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Identifiers\u003C/code\u003E contains additional groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior\noptions.Identifiers.Validation\noptions.Identifiers.Normalization\noptions.Identifiers.Uniqueness\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese configure identifier behavior beyond the top-level validation rules.\u003C/p\u003E\n\u003Cp\u003EIdentifier normalization and uniqueness are particularly important because they affect how duplicate identifiers are detected.\u003C/p\u003E\n\u003Cp\u003EUse the provided options rather than implementing inconsistent normalization independently in the client.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profile-options\u0022\u003E18. User Profile Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.UserProfile\u003C/code\u003E controls profile-related capabilities.\u003C/p\u003E\n\u003Cp\u003EThe current option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.UserProfile.EnableMultiProfile\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-multiple-profiles\u0022\u003EEnable Multiple Profiles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.UserProfile.EnableMultiProfile = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to use UltimateAuth\u0027s multiple-profile capability where supported by the user domain.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may maintain separate profile contexts for different purposes.\u003C/p\u003E\n\u003Cp\u003EMulti-profile support does not create separate user identities or independent authentication accounts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-options\u0022\u003E19. Multi-Tenant Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.MultiTenant\u003C/code\u003E controls whether tenant-aware behavior is enabled and how tenant identifiers are resolved.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-4\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnabled\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizeToLowercase\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalize tenant identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableRoute\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from headers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableDomain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from hosts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-Tenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant header name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-header-based-tenant-resolution\u0022\u003EExample: Header-Based Tenant Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableHeader = true;\n options.MultiTenant.HeaderName = \u0026quot;X-Tenant\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-domain-based-resolution\u0022\u003EExample: Domain-Based Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableDomain = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMultiple resolution mechanisms can be enabled.\u003C/p\u003E\n\u003Cp\u003EThe server composes the configured tenant resolvers according to its resolution infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-boundary\u0022\u003ESecurity Boundary\u003C/h3\u003E\n\u003Cp\u003ETenant identification is not tenant authorization.\u003C/p\u003E\n\u003Cp\u003EA request providing another tenant identifier must not gain access to that tenant without appropriate authorization.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination-options\u0022\u003E20. Pagination Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pagination\u003C/code\u003E controls pagination defaults and maximum page sizes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E250\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1000\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-4\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings affect paginated queries exposed by UltimateAuth domain services.\u003C/p\u003E\n\u003Cp\u003EFor administrative interfaces, choosing a moderate page size can reduce unnecessary data transfer and processing.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigation-options\u0022\u003E21. Navigation Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Navigation\u003C/code\u003E configures automatic authentication navigation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-5\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableAutomaticNavigationRedirect\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessDeniedResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirects\u0022\u003EDisable Automatic Redirects\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.EnableAutomaticNavigationRedirect = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-redirect\u0022\u003ECustom Login Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.LoginResolver = context =\u0026gt;\n {\n return \u0026quot;/account/login\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-access-denied-redirect\u0022\u003ECustom Access Denied Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.AccessDeniedResolver = context =\u0026gt;\n {\n return \u0026quot;/account/access-denied\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese delegates receive the current \u003Ccode\u003EHttpContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe application should return valid, trusted navigation destinations.\u003C/p\u003E\n\u003Cp\u003EAvoid constructing redirects from unvalidated user-controlled URLs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-response-options\u0022\u003E22. Authentication Response Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.AuthResponse\u003C/code\u003E controls how authentication credentials and redirect behavior are represented in responses.\u003C/p\u003E\n\u003Cp\u003EThe available groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AuthResponse.SessionIdDelivery\noptions.AuthResponse.AccessTokenDelivery\noptions.AuthResponse.RefreshTokenDelivery\n\noptions.AuthResponse.Login\noptions.AuthResponse.Logout\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings govern credential delivery and authentication response behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-mode-specific-response-defaults\u0022\u003EImportant: Mode-Specific Response Defaults\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides response templates for authentication modes.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E uses a session-oriented cookie response, while \u003Ccode\u003EHybrid\u003C/code\u003E combines session and token delivery behavior.\u003C/p\u003E\n\u003Cp\u003EThe effective response may therefore depend on the resolved authentication mode and flow.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a single globally configured response delivery setting applies unchanged to every authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ECredential delivery settings determine whether sensitive authentication material is exposed through cookies, headers, or other response mechanisms.\u003C/p\u003E\n\u003Cp\u003EOnly customize them when the application has a clear requirement and the selected transport is appropriate for the client platform.\u003C/p\u003E\n\u003Cp\u003EFor advanced response customization, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022diagnostics-options\u0022\u003E23. Diagnostics Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Diagnostics\u003C/code\u003E provides optional diagnostic behavior.\u003C/p\u003E\n\u003Cp\u003EThe current property is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Diagnostics.EnableRefreshDetails\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-5\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen enabled, the option allows additional refresh-related diagnostic response information, such as the \u003Ccode\u003EX-UAuth-Refresh\u003C/code\u003E header.\u003C/p\u003E\n\u003Cp\u003EThis is primarily useful during development and debugging.\u003C/p\u003E\n\u003Cp\u003EAvoid enabling detailed authentication diagnostics in production unless the exposure is understood and justified.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-lifecycle-events\u0022\u003E24. Authentication Lifecycle Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Events\u003C/code\u003E exposes server-side authentication lifecycle callbacks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-events\u0022\u003EAvailable Events\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Events.OnAnyEvent\noptions.Events.OnUserLoggedIn\noptions.Events.OnUserLoggedOut\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-login-logging\u0022\u003EExample: Login Logging\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnUserLoggedIn = context =\u0026gt;\n {\n // Record the successful login.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-global-event-handler\u0022\u003EExample: Global Event Handler\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnAnyEvent = context =\u0026gt;\n {\n // Process an authentication lifecycle event.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELifecycle events are intended for lightweight reactions such as logging, metrics, and notifications.\u003C/p\u003E\n\u003Cp\u003EThey should not replace security decisions, authentication validation, or application policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for event and extension guidance.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022effective-server-options\u0022\u003E25. Effective Server Options\u003C/h2\u003E\n\u003Cp\u003EThe configured \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance is the base configuration.\u003C/p\u003E\n\u003Cp\u003EAt runtime, UltimateAuth can resolve:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EEffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current resolution process is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EConfigured UAuthServerOptions\n |\n v\n Resolve auth mode\n |\n v\n Clone base options\n |\n v\n Apply mode defaults\n |\n v\n Apply ConfigureMode overrides\n |\n v\n EffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis means that the effective settings used for a \u003Ccode\u003EHybrid\u003C/code\u003E client can differ from those used for a \u003Ccode\u003EPureOpaque\u003C/code\u003E client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-6\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(3);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe original options remain the common starting point.\u003C/p\u003E\n\u003Cp\u003EMode defaults and overrides determine the effective values for the resolved mode.\u003C/p\u003E\n\u003Cp\u003EFor implementation details and inspection of effective options, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-configuration-safety\u0022\u003E26. Validation and Configuration Safety\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth Server registers multiple options validators and enables startup validation.\u003C/p\u003E\n\u003Cp\u003EThese validators cover areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EPKCE.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EIdentifier configuration.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server rejects an invalid session lifetime under the applicable validation rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-base-path\u0022\u003EExample: Invalid Base Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe general server validator rejects paths containing \u003Ccode\u003E//\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current validator rejects explicitly enabling \u003Ccode\u003EPureJwt\u003C/code\u003E because that mode is not yet implemented as a supported server mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-limitations\u0022\u003EValidation Limitations\u003C/h3\u003E\n\u003Cp\u003EStartup validation protects the configured options according to the registered validators.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that every custom service implementation or mode-specific override is secure.\u003C/p\u003E\n\u003Cp\u003EWhen using advanced customization, test the resulting effective configuration and authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-configuration-example\u0022\u003E27. Complete Configuration Example\u003C/h2\u003E\n\u003Cp\u003EThe following example combines several commonly used server settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Authentication modes\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n\n // Login security\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.IncludeFailureDetails = false;\n\n // Sessions\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n\n // Token identity\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n\n // Hybrid-specific token lifetime\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n });\n\n // PKCE\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n\n // Hub deployment\n options.HubDeploymentMode = UAuthHubDeploymentMode.Integrated;\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n\n // Credential security\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n\n // Credential recovery\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(30);\n options.ResetCredential.MaxAttempts = 3;\n\n // Identifier login rules\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n\n // Session transport\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableQuery = false;\n\n // Endpoint exposure\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Credentials = true;\n options.Endpoints.Authorization = true;\n\n // Pagination\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n\n // Diagnostics\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an illustrative configuration, not a universal production preset.\u003C/p\u003E\n\u003Cp\u003EApplications should choose session lifetimes, authentication modes, credential transport, tenant behavior, and endpoint exposure according to their own deployment and security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-configuration-mistakes\u0022\u003E28. Common Configuration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-core-options-separately\u0022\u003EConfiguring Core Options Separately\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, configure Core-defined options through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate \u003Ccode\u003EUAuthOptions\u003C/code\u003E configuration does not automatically override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-allowedmodes-selects-the-mode\u0022\u003EAssuming AllowedModes Selects the Mode\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction.\u003C/p\u003E\n\u003Cp\u003EMode selection is performed by the authentication mode resolver.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ignoring-mode-defaults\u0022\u003EIgnoring Mode Defaults\u003C/h3\u003E\n\u003Cp\u003EMode-specific defaults can replace globally configured values.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for explicit mode-specific behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enabling-unsupported-modes\u0022\u003EEnabling Unsupported Modes\u003C/h3\u003E\n\u003Cp\u003EAn authentication mode appearing in an enum does not guarantee runtime support.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-reserved-session-options-as-enforced-security-features\u0022\u003ETreating Reserved Session Options as Enforced Security Features\u003C/h3\u003E\n\u003Cp\u003ESome device and session-chain options are reserved for future enforcement.\u003C/p\u003E\n\u003Cp\u003EVerify actual runtime support before relying on them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022exposing-unnecessary-endpoints\u0022\u003EExposing Unnecessary Endpoints\u003C/h3\u003E\n\u003Cp\u003EDisable endpoint groups or actions that the application does not require.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022allowing-query-string-credentials-without-a-specific-need\u0022\u003EAllowing Query-String Credentials Without a Specific Need\u003C/h3\u003E\n\u003Cp\u003EQuery-string credentials can leak through URLs and infrastructure logs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022weakening-cookie-security\u0022\u003EWeakening Cookie Security\u003C/h3\u003E\n\u003Cp\u003EDo not relax secure transport or cookie protections without understanding the consequences.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-configuration-validation-covers-everything\u0022\u003EAssuming Configuration Validation Covers Everything\u003C/h3\u003E\n\u003Cp\u003EStartup validation does not replace testing, infrastructure security, or validation of custom extension implementations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-quick-reference\u0022\u003E29. Server Options Quick Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConfiguration group\u003C/th\u003E\n\u003Cth\u003ETypical use\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession expiration and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance and lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EQuery page limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint exposure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential source precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie security and scope\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment topology\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUI/API primary credential selection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential delivery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier eligibility\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMulti-profile capability\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication redirects\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EConfigureMode()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMode-specific option overrides\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdvanced endpoint customization\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the configuration architecture and precedence.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, mode resolution, and extension points.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage sessions through the client.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Use the client authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the primary configuration surface for UltimateAuth Server.\u003C/p\u003E\n\u003Cp\u003EIt controls authentication behavior, session lifecycles, token issuance, credential security, tenant resolution, endpoint exposure, and integration with UAuthHub.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth composes its Core-defined options directly into \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E. When using UltimateAuth Server, you configure both shared authentication behavior and server-specific features through the same options object.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior.\u003C/p\u003E\n\u003Cp\u003EBuilt-in HTTP throttling is configured through \u003Ccode\u003Eoptions.RateLimiting\u003C/code\u003E. See \u003Ca href=\u0022/docs/security/rate-limiting\u0022\u003ERate Limiting\u003C/a\u003E for defaults, middleware ownership, proxy configuration and native-policy integration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-ultimateauth-server\u0022\u003E1. Register UltimateAuth Server\u003C/h2\u003E\n\u003Cp\u003EConfigure the server through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration delegate receives:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server registration also supports an optional policy configuration delegate.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(\n configure: options =\u0026gt;\n {\n options.Login.MaxFailedAttempts = 5;\n },\n configurePolicies: policies =\u0026gt;\n {\n // Register application access policies.\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server automatically registers its Core infrastructure. You do not need to call \u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E separately.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-from-appsettings.json\u0022\u003EConfiguration from appsettings.json\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth Server binds the configuration section:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;7.00:00:00\u0026quot;\n },\n \u0026quot;Token\u0026quot;: {\n \u0026quot;AccessTokenLifetime\u0026quot;: \u0026quot;00:10:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EASP.NET Core represents \u003Ccode\u003ETimeSpan\u003C/code\u003E values using strings such as \u003Ccode\u003E00:15:00\u003C/code\u003E for fifteen minutes and \u003Ccode\u003E7.00:00:00\u003C/code\u003E for seven days.\u003C/p\u003E\n\u003Cp\u003EThe configuration section is bound after the registration delegate, so configuration provider values take precedence for matching properties.\u003C/p\u003E\n\u003Cp\u003EFor configuration source ordering and environment-specific overrides, see \u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-architecture\u0022\u003E2. Server Options Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E contains two categories of configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-defined-options\u0022\u003ECore-defined options\u003C/h3\u003E\n\u003Cp\u003EThese types are defined in UltimateAuth Core and composed into the server options:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthTokenOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh token behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPkceOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE authorization code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthMultiTenantOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant identification and resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPaginationOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination defaults and limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-specific-options\u0022\u003EServer-specific options\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestrict permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribe the relationship between UAuthHub and the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration and flow settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie names, security attributes, and lifetime behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession identifier sources and precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint groups and disabled actions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential selection for UI and API requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication credential delivery and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery code and token settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior, normalization, and uniqueness\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername, email, and phone validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types allowed for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile capabilities\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication navigation behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional diagnostic response details\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdvanced configuration hooks are also available, including \u003Ccode\u003EConfigureMode()\u003C/code\u003E and \u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-options\u0022\u003E3. Login Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Login\u003C/code\u003E controls login attempt limits, lockout behavior, and related security settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.FailureWindow = TimeSpan.FromMinutes(15);\n options.Login.ExtendLockOnFailure = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFailed login threshold; \u003Ccode\u003E0\u003C/code\u003E disables lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003ELockout duration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003EWindow for counting failed attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExtendLockOnFailure\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExtend lockout when another attempt fails during lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeFailureDetails\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude detailed authentication failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E180 seconds\u003C/td\u003E\n\u003Ctd\u003EDuration associated with the try-login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restrict-login-failure-details\u0022\u003EExample: Restrict Login Failure Details\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.IncludeFailureDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is useful when an application does not want to expose detailed lockout information to callers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important\u0022\u003EImportant\u003C/h3\u003E\n\u003Cp\u003ELockout configuration is not equivalent to complete network-level rate limiting.\u003C/p\u003E\n\u003Cp\u003EProduction deployments should also consider infrastructure protections against abusive traffic and distributed login attempts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-options\u0022\u003E4. Session Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Session\u003C/code\u003E controls session lifetime, expiration, sliding behavior, and session-chain limits.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.SlidingExpiration = true;\n options.Session.IdleTimeout = TimeSpan.FromDays(3);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-lifetime-properties\u0022\u003ESession Lifetime Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003EStandard session lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional absolute lifetime limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESlidingExpiration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAllow eligible activity or refresh operations to extend expiration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional inactivity timeout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003EMinimum interval between last-seen updates\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings have different purposes.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ELifetime\u003C/code\u003E determines the standard lifetime assigned to a session.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E establishes an optional hard boundary that cannot be extended indefinitely.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E limits how long a session can remain inactive.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E reduces unnecessary persistence operations caused by frequent activity updates.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chain-properties\u0022\u003ESession-Chain Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved user-level chain limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxSessionsPerChain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E100\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession-chain rotation limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatformCategories\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform group definitions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerCategory\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved category-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableIpBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved IP binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableUserAgentBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved User-Agent binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceMismatchBehavior\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EReject\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved device mismatch behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation limitation:\u003C/strong\u003E The source identifies several device limits and binding settings as reserved for future enforcement. Setting these properties must not be treated as proof that the corresponding protection is active.\u003C/p\u003E\n\u003Cp\u003EFor example, do not rely on:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.MaxChainsPerUser = 1;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eas a production guarantee that only one device chain can exist unless the current runtime implementation has been verified to enforce that restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-specific-session-defaults\u0022\u003EMode-Specific Session Defaults\u003C/h3\u003E\n\u003Cp\u003ESession settings can also be affected by authentication mode defaults.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E supplies a longer idle timeout and touch interval when those values have not been configured.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EHybrid\u003C/code\u003E explicitly sets \u003Ccode\u003ETouchInterval\u003C/code\u003E to \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for the effective-options resolution order.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-options\u0022\u003E5. Token Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Token\u003C/code\u003E configures access and refresh token behavior.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-1\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueJwt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable JWT issuance where supported by the effective mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueOpaque\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable opaque token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable refresh token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003ERefresh token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOpaqueIdBytes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E32\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERandom byte length used for opaque token identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenConcurrentRequestWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E2 seconds\u003C/td\u003E\n\u003Ctd\u003EConcurrent duplicate refresh classification window\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssuer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT issuer\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAudience\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT audience\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddJwtIdClaim\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude a JWT identifier claim\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeyId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional signing key identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-token-concurrency-window\u0022\u003ERefresh Token Concurrency Window\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromSeconds(2);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls how the server distinguishes a closely timed duplicate refresh request from a confirmed replay.\u003C/p\u003E\n\u003Cp\u003EA duplicate refresh request is still rejected.\u003C/p\u003E\n\u003Cp\u003EThe window affects the replay-response classification, including whether the token family should be revoked.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E allow the same consumed refresh token to be used successfully more than once.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-defaults-may-override-token-values\u0022\u003EMode Defaults May Override Token Values\u003C/h3\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003EHybrid\u003C/code\u003E configuration explicitly sets:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EToken.AccessTokenLifetime = TimeSpan.FromMinutes(10);\nToken.RefreshTokenLifetime = TimeSpan.FromDays(7);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETherefore, a different value configured globally may be replaced when the effective mode is resolved.\u003C/p\u003E\n\u003Cp\u003ETo customize the lifetime for \u003Ccode\u003EHybrid\u003C/code\u003E, use:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis distinction is essential when configuring token lifetimes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-options\u0022\u003E6. PKCE Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pkce\u003C/code\u003E configures the lifetime of PKCE authorization codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCodeLifetimeSeconds\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E120\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELifetime of a PKCE authorization code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is particularly important for browser-based clients such as Blazor WebAssembly.\u003C/p\u003E\n\u003Cp\u003EA shorter authorization code lifetime reduces the period during which an intercepted or exposed code could potentially be used.\u003C/p\u003E\n\u003Cp\u003EHowever, the value must remain long enough for legitimate authorization exchanges to complete.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s PKCE configuration is separate from the client\u0027s PKCE flow settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003E7. Authentication Modes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports authentication mode resolution based on the client profile.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property restricts the modes the server is permitted to use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction, not a mode selector.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved by \u003Ccode\u003EIEffectiveAuthModeResolver\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-mode-resolution\u0022\u003EDefault Mode Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EWebServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorWasm\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaui\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation has mode templates for \u003Ccode\u003EPureOpaque\u003C/code\u003E, \u003Ccode\u003EHybrid\u003C/code\u003E, \u003Ccode\u003ESemiHybrid\u003C/code\u003E, and \u003Ccode\u003EPureJwt\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled modes by the current server options validator.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable an unsupported mode merely because it exists in the enum or has a mode-default template.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EApi\u003C/code\u003E profile mapping to \u003Ccode\u003EPureJwt\u003C/code\u003E is a current compatibility limitation to consider when deploying API-profile authentication flows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-mode-specific-behavior\u0022\u003EConfigure Mode-Specific Behavior\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for behavior that should differ by mode.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(7);\n });\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mode override runs after the built-in mode defaults.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-options\u0022\u003E8. Endpoint Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Endpoints\u003C/code\u003E controls endpoint paths, feature groups, and individual action restrictions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022base-path\u0022\u003EBase Path\u003C/h3\u003E\n\u003Cp\u003EThe default server endpoint base path is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E/auth\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EChanging the base path can help avoid route conflicts when integrating UltimateAuth into an existing application.\u003C/p\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-groups\u0022\u003EEndpoint Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserLifecycle\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser lifecycle endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentials\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorization\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthorization endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Authorization = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDisabling an endpoint group controls endpoint exposure. It does not automatically remove the underlying domain services or invalidate previously issued credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-individual-actions\u0022\u003EDisable Individual Actions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EDisabledActions\u003C/code\u003E is a collection of action identifiers:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.DisabledActions.Add(\u0026quot;users.create.anonymous\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe string must match the action identifier used by the server\u0027s endpoint registration and action policy.\u003C/p\u003E\n\u003Cp\u003EDo not pass a C# constant name as a string unless that exact text is also the registered action value.\u003C/p\u003E\n\u003Cp\u003EFor example, these are not necessarily equivalent:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;UAuthActions.Users.Create.Anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eand:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;users.create.anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the actual action value defined by the corresponding \u003Ccode\u003EUAuthActions\u003C/code\u003E constant.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-hardening\u0022\u003EEndpoint Hardening\u003C/h3\u003E\n\u003Cp\u003EDisable endpoints that are not required by the application.\u003C/p\u003E\n\u003Cp\u003EFor example, an application that delegates user provisioning to administrators may choose to disable anonymous user creation.\u003C/p\u003E\n\u003Cp\u003EEndpoint exposure is a separate concern from authorization: an exposed endpoint must still enforce the applicable security policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-resolution\u0022\u003E9. Session Resolution\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.SessionResolution\u003C/code\u003E controls how incoming requests provide session identifiers.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports four resolution sources:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EBearer credentials.\u003C/li\u003E\n\u003Cli\u003ECustom headers.\u003C/li\u003E\n\u003Cli\u003ECookies.\u003C/li\u003E\n\u003Cli\u003EQuery parameters.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-configuration\u0022\u003EDefault Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableBearer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableQuery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-UAuth-Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryParameterName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Esession_id\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default precedence order is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBearer\nHeader\nCookie\nQuery\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-resolution-sources\u0022\u003EConfigure Resolution Sources\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableHeader = false;\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableQuery = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example disables custom-header and query-string session resolution.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-resolution-order\u0022\u003EChange Resolution Order\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.Order =\n [\n \u0026quot;Cookie\u0026quot;,\n \u0026quot;Bearer\u0026quot;,\n \u0026quot;Header\u0026quot;,\n \u0026quot;Query\u0026quot;\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResolution order matters when a request contains multiple candidate credentials.\u003C/p\u003E\n\u003Cp\u003EThe selected credential must still pass authentication and session validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EAvoid query-string credentials unless the application has a specific, well-understood requirement.\u003C/p\u003E\n\u003Cp\u003EURLs may be recorded in browser history, reverse-proxy logs, analytics systems, and other infrastructure.\u003C/p\u003E\n\u003Cp\u003EFor most browser and API integrations, cookies or authorization headers are preferable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-configuration\u0022\u003E10. Cookie Configuration\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Cookie\u003C/code\u003E controls cookies used for authentication credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth defines three cookie groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Cookie.Session\noptions.Cookie.AccessToken\noptions.Cookie.RefreshToken\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-cookie-names\u0022\u003EDefault Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECookie\u003C/th\u003E\n\u003Cth\u003EDefault name\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euas\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccess token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euat\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euar\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default cookie options use:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EHttpOnly = true\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ESecurePolicy = CookieSecurePolicy.Always\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPath = \u0026quot;/\u0026quot;\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDomain = null\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMaxAge = null\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003ESameSite\u003C/code\u003E is nullable and can be resolved through the applicable cookie and deployment policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-cookie-names\u0022\u003ECustomize Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Name = \u0026quot;myapp_session\u0026quot;;\n options.Cookie.RefreshToken.Name = \u0026quot;myapp_refresh\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-cookie-scope\u0022\u003EConfigure Cookie Scope\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Path = \u0026quot;/\u0026quot;;\n options.Cookie.Session.Domain = null;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA null domain produces a host-only cookie.\u003C/p\u003E\n\u003Cp\u003ESetting a parent domain can broaden cookie availability to subdomains and should be done only when necessary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-security\u0022\u003ECookie Security\u003C/h3\u003E\n\u003Cp\u003EAvoid disabling \u003Ccode\u003EHttpOnly\u003C/code\u003E or relaxing secure transport requirements for authentication cookies.\u003C/p\u003E\n\u003Cp\u003ECookie settings interact with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe deployment mode.\u003C/li\u003E\n\u003Cli\u003ECross-site requests.\u003C/li\u003E\n\u003Cli\u003EBrowser cookie policies.\u003C/li\u003E\n\u003Cli\u003ECredential delivery configuration.\u003C/li\u003E\n\u003Cli\u003EHTTPS requirements.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor cross-site deployments, configure cookie behavior together with CORS and the relevant browser security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-deployment-mode\u0022\u003E11. Hub Deployment Mode\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.HubDeploymentMode\u003C/code\u003E describes how UAuthHub is deployed relative to the application.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-modes\u0022\u003EAvailable Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmbedded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub and application share the same origin\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIntegrated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is separate but within the same-site boundary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExternal\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is hosted on a different site or domain\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthHubDeploymentMode.Integrated\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022embedded\u0022\u003EEmbedded\u003C/h3\u003E\n\u003Cp\u003ESuitable for applications hosting UltimateAuth within the same application and origin.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022integrated\u0022\u003EIntegrated\u003C/h3\u003E\n\u003Cp\u003ESuitable for deployments such as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eauth.example.com\napp.example.com\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Ewhere the authentication service and application are separate origins within the same site.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022external\u0022\u003EExternal\u003C/h3\u003E\n\u003Cp\u003ESuitable for a separate authentication provider domain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.External;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EExternal deployment requires careful coordination of cookie policies, redirects, allowed origins, and browser restrictions.\u003C/p\u003E\n\u003Cp\u003EThe deployment mode influences authentication transport behavior; it is not merely descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-options\u0022\u003E12. Hub Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Hub\u003C/code\u003E configures UAuthHub integration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-2\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientBaseAddress\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional client application base address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty set\u003C/td\u003E\n\u003Ctd\u003EAllowed client origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003ELifetime of Hub flow artifacts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginPath\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E/login\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub login page path\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n options.Hub.FlowLifetime = TimeSpan.FromMinutes(5);\n options.Hub.LoginPath = \u0026quot;/login\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub flow lifetime is not the same as the authenticated session lifetime.\u003C/p\u003E\n\u003Cp\u003EHub artifacts support authentication orchestration and should generally be short-lived.\u003C/p\u003E\n\u003Cp\u003EOnly trusted origins should be included in \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-credential-policy\u0022\u003E13. Primary Credential Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.PrimaryCredential\u003C/code\u003E controls which credential kind is preferred for UI and API requests.\u003C/p\u003E\n\u003Cp\u003EThe defaults are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.AccessToken\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-1\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.PrimaryCredential.Ui = PrimaryTokenKind.Session;\n options.PrimaryCredential.Api = PrimaryTokenKind.AccessToken;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows UltimateAuth to distinguish between session-oriented browser interactions and access-token-oriented API interactions.\u003C/p\u003E\n\u003Cp\u003EThe primary credential policy does not bypass authentication validation.\u003C/p\u003E\n\u003Cp\u003EIt determines which credential kind should be preferred within the applicable request-processing rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-session-policy\u0022\u003E14. Credential Session Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.CredentialSessionPolicy\u003C/code\u003E determines what happens to active session chains after credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-3\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser changes their own credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdministrator changes a user\u0027s credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery completes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-2\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-behaviors\u0022\u003EAvailable Behaviors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EEffect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep the current chain and revoke other applicable chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor recovery flows, \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not always meaningful because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EThe current implementation treats this situation using the more restrictive \u003Ccode\u003ERevokeAll\u003C/code\u003E behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation-1\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EPreserving the current session after a normal password change can provide a reasonable user experience.\u003C/p\u003E\n\u003Cp\u003ERevoking all sessions after administrative credential changes or account recovery is generally more conservative.\u003C/p\u003E\n\u003Cp\u003EChoose the policy according to the application\u0027s security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-reset-options\u0022\u003E15. Credential Reset Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.ResetCredential\u003C/code\u003E configures credential recovery proof lifetimes and attempt limits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022defaults\u0022\u003EDefaults\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETokenValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E30 minutes\u003C/td\u003E\n\u003Ctd\u003EReset token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum permitted recovery attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E6\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code length\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-3\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(20);\n options.ResetCredential.CodeValidity = TimeSpan.FromMinutes(5);\n options.ResetCredential.MaxAttempts = 3;\n options.ResetCredential.CodeLength = 6;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E applies to numeric reset codes, not token-based recovery proofs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-security-boundary\u0022\u003EImportant Security Boundary\u003C/h3\u003E\n\u003Cp\u003EReset proofs must be delivered through a trusted server-side mechanism.\u003C/p\u003E\n\u003Cp\u003EDo not return recovery tokens to anonymous callers simply to simplify a client-side reset form.\u003C/p\u003E\n\u003Cp\u003EThe application should provide the appropriate \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E implementation for supported recovery flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-options\u0022\u003E16. Login Identifier Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.LoginIdentifiers\u003C/code\u003E determines which identifier types can be used for login.\u003C/p\u003E\n\u003Cp\u003EThe default allowed types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUserIdentifierType.Username\nUserIdentifierType.Email\nUserIdentifierType.Phone\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-login-identifier-types\u0022\u003ERestrict Login Identifier Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.AllowedTypes =\n new HashSet\u0026lt;UserIdentifierType\u0026gt;\n {\n UserIdentifierType.Email\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configures email as the allowed login identifier type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022require-verification\u0022\u003ERequire Verification\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n options.LoginIdentifiers.RequireVerificationForPhone = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control whether the corresponding identifiers must be verified before being accepted for login.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-resolvers\u0022\u003ECustom Resolvers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCustomResolvers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECustomResolversFirst\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese options control the participation and ordering of custom login identifier resolvers.\u003C/p\u003E\n\u003Cp\u003EAn identifier existing on a user account does not necessarily mean it is permitted for login.\u003C/p\u003E\n\u003Cp\u003EIdentifier management and login eligibility are separate concerns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-validation\u0022\u003E17. Identifier Validation\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.IdentifierValidation\u003C/code\u003E provides rules for usernames, email addresses, and phone numbers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022username-rules\u0022\u003EUsername Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.IdentifierValidation.UserName.Enabled = true;\n options.IdentifierValidation.UserName.MinLength = 3;\n options.IdentifierValidation.UserName.MaxLength = 32;\n options.IdentifierValidation.UserName.AllowedRegex = \u0026quot;^[a-zA-Z0-9._-]\u002B$\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-validation-rules\u0022\u003EDefault Validation Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EIdentifier\u003C/th\u003E\n\u003Cth\u003EEnabled\u003C/th\u003E\n\u003Cth\u003EMinimum length\u003C/th\u003E\n\u003Cth\u003EMaximum length\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E64\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EEmail\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E256\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPhone\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E24\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUsername validation additionally supports \u003Ccode\u003EAllowedRegex\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default username expression is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EREGEX\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-regex\u0022\u003E^[a-zA-Z0-9._-]\u002B$\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-behavior\u0022\u003EIdentifier Behavior\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Identifiers\u003C/code\u003E contains additional groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior\noptions.Identifiers.Validation\noptions.Identifiers.Normalization\noptions.Identifiers.Uniqueness\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese configure identifier behavior beyond the top-level validation rules.\u003C/p\u003E\n\u003Cp\u003EIdentifier normalization and uniqueness are particularly important because they affect how duplicate identifiers are detected.\u003C/p\u003E\n\u003Cp\u003EUse the provided options rather than implementing inconsistent normalization independently in the client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-limits\u0022\u003EIdentifier Limits\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth supports configurable limits on the number of identifiers associated with each user.\u003C/p\u003E\n\u003Cp\u003EBy default, a user can have up to \u003Cstrong\u003E10 active identifiers\u003C/strong\u003E, regardless of their type.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a global per-user limit. It applies to the combined number of usernames, email addresses, phone numbers, and custom identifiers.\u003C/p\u003E\n\u003Ch4 id=\u0022optional-per-type-limits\u0022\u003EOptional Per-Type Limits\u003C/h4\u003E\n\u003Cp\u003EApplications can optionally define limits for individual identifier types:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\noptions.Identifiers.Behavior.MaxIdentifiersPerType = new()\n{\n [UserIdentifierType.Username] = 2,\n [UserIdentifierType.Email] = 5,\n [UserIdentifierType.Phone] = 3\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EPer-type limits are not configured by default.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EWhen no per-type limit is specified, only the global limit applies.\u003C/p\u003E\n\u003Cp\u003EFor example, with the configuration above, a user may have up to five email addresses, but the combined number of all active identifiers cannot exceed ten.\u003C/p\u003E\n\u003Ch4 id=\u0022default-behavior\u0022\u003EDefault Behavior\u003C/h4\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum total active identifiers per user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty dictionary\u003C/td\u003E\n\u003Ctd\u003EOptional limits for individual identifier types\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following rules apply:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EPrimary and non-primary identifiers count equally.\u003C/li\u003E\n\u003Cli\u003ESoft-deleted identifiers do not count toward active limits.\u003C/li\u003E\n\u003Cli\u003ESelf-service and administrative operations follow the same limits.\u003C/li\u003E\n\u003Cli\u003EPer-type limits do not override the global limit.\u003C/li\u003E\n\u003Cli\u003EExisting identifier behavior options remain applicable.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAllowMultipleUsernames = false\u003C/code\u003E still prevents multiple usernames, even when the configured username limit is greater than one.\u003C/p\u003E\n\u003Ch4 id=\u0022limit-validation\u0022\u003ELimit Validation\u003C/h4\u003E\n\u003Cp\u003EWhen an identifier addition exceeds a configured limit, the operation is rejected with a validation error.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EError Code\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_total_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the global identifier limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_type_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the configured limit for that identifier type\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EApplications should handle these errors and display appropriate feedback.\u003C/p\u003E\n\u003Cp\u003EIdentifier limits are enforced by the Users application layer rather than individual persistence stores.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profile-options\u0022\u003E18. User Profile Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.UserProfile\u003C/code\u003E controls profile-related capabilities.\u003C/p\u003E\n\u003Cp\u003EThe current option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.UserProfile.EnableMultiProfile\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-multiple-profiles\u0022\u003EEnable Multiple Profiles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.UserProfile.EnableMultiProfile = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to use UltimateAuth\u0027s multiple-profile capability where supported by the user domain.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may maintain separate profile contexts for different purposes.\u003C/p\u003E\n\u003Cp\u003EMulti-profile support does not create separate user identities or independent authentication accounts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-options\u0022\u003E19. Multi-Tenant Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.MultiTenant\u003C/code\u003E controls whether tenant-aware behavior is enabled and how tenant identifiers are resolved.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-4\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnabled\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizeToLowercase\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalize tenant identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableRoute\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from headers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableDomain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from hosts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-Tenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant header name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-header-based-tenant-resolution\u0022\u003EExample: Header-Based Tenant Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableHeader = true;\n options.MultiTenant.HeaderName = \u0026quot;X-Tenant\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-domain-based-resolution\u0022\u003EExample: Domain-Based Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableDomain = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMultiple resolution mechanisms can be enabled.\u003C/p\u003E\n\u003Cp\u003EThe server composes the configured tenant resolvers according to its resolution infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-boundary\u0022\u003ESecurity Boundary\u003C/h3\u003E\n\u003Cp\u003ETenant identification is not tenant authorization.\u003C/p\u003E\n\u003Cp\u003EA request providing another tenant identifier must not gain access to that tenant without appropriate authorization.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination-options\u0022\u003E20. Pagination Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pagination\u003C/code\u003E controls pagination defaults and maximum page sizes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E250\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1000\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-4\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings affect paginated queries exposed by UltimateAuth domain services.\u003C/p\u003E\n\u003Cp\u003EFor administrative interfaces, choosing a moderate page size can reduce unnecessary data transfer and processing.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigation-options\u0022\u003E21. Navigation Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Navigation\u003C/code\u003E configures automatic authentication navigation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-5\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableAutomaticNavigationRedirect\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessDeniedResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirects\u0022\u003EDisable Automatic Redirects\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.EnableAutomaticNavigationRedirect = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-redirect\u0022\u003ECustom Login Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.LoginResolver = context =\u0026gt;\n {\n return \u0026quot;/account/login\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-access-denied-redirect\u0022\u003ECustom Access Denied Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.AccessDeniedResolver = context =\u0026gt;\n {\n return \u0026quot;/account/access-denied\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese delegates receive the current \u003Ccode\u003EHttpContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe application should return valid, trusted navigation destinations.\u003C/p\u003E\n\u003Cp\u003EAvoid constructing redirects from unvalidated user-controlled URLs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-response-options\u0022\u003E22. Authentication Response Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.AuthResponse\u003C/code\u003E controls how authentication credentials and redirect behavior are represented in responses.\u003C/p\u003E\n\u003Cp\u003EThe available groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AuthResponse.SessionIdDelivery\noptions.AuthResponse.AccessTokenDelivery\noptions.AuthResponse.RefreshTokenDelivery\n\noptions.AuthResponse.Login\noptions.AuthResponse.Logout\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings govern credential delivery and authentication response behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-mode-specific-response-defaults\u0022\u003EImportant: Mode-Specific Response Defaults\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides response templates for authentication modes.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E uses a session-oriented cookie response, while \u003Ccode\u003EHybrid\u003C/code\u003E combines session and token delivery behavior.\u003C/p\u003E\n\u003Cp\u003EThe effective response may therefore depend on the resolved authentication mode and flow.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a single globally configured response delivery setting applies unchanged to every authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ECredential delivery settings determine whether sensitive authentication material is exposed through cookies, headers, or other response mechanisms.\u003C/p\u003E\n\u003Cp\u003EOnly customize them when the application has a clear requirement and the selected transport is appropriate for the client platform.\u003C/p\u003E\n\u003Cp\u003EFor advanced response customization, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022diagnostics-options\u0022\u003E23. Diagnostics Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Diagnostics\u003C/code\u003E provides optional diagnostic behavior.\u003C/p\u003E\n\u003Cp\u003EThe current property is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Diagnostics.EnableRefreshDetails\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-5\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen enabled, the option allows additional refresh-related diagnostic response information, such as the \u003Ccode\u003EX-UAuth-Refresh\u003C/code\u003E header.\u003C/p\u003E\n\u003Cp\u003EThis is primarily useful during development and debugging.\u003C/p\u003E\n\u003Cp\u003EAvoid enabling detailed authentication diagnostics in production unless the exposure is understood and justified.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-lifecycle-events\u0022\u003E24. Authentication Lifecycle Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Events\u003C/code\u003E exposes server-side authentication lifecycle callbacks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-events\u0022\u003EAvailable Events\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Events.OnAnyEvent\noptions.Events.OnUserLoggedIn\noptions.Events.OnUserLoggedOut\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-login-logging\u0022\u003EExample: Login Logging\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnUserLoggedIn = context =\u0026gt;\n {\n // Record the successful login.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-global-event-handler\u0022\u003EExample: Global Event Handler\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnAnyEvent = context =\u0026gt;\n {\n // Process an authentication lifecycle event.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELifecycle events are intended for lightweight reactions such as logging, metrics, and notifications.\u003C/p\u003E\n\u003Cp\u003EThey should not replace security decisions, authentication validation, or application policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for event and extension guidance.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022effective-server-options\u0022\u003E25. Effective Server Options\u003C/h2\u003E\n\u003Cp\u003EThe configured \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance is the base configuration.\u003C/p\u003E\n\u003Cp\u003EAt runtime, UltimateAuth can resolve:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EEffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current resolution process is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EConfigured UAuthServerOptions\n |\n v\n Resolve auth mode\n |\n v\n Clone base options\n |\n v\n Apply mode defaults\n |\n v\n Apply ConfigureMode overrides\n |\n v\n EffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis means that the effective settings used for a \u003Ccode\u003EHybrid\u003C/code\u003E client can differ from those used for a \u003Ccode\u003EPureOpaque\u003C/code\u003E client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-6\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(3);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe original options remain the common starting point.\u003C/p\u003E\n\u003Cp\u003EMode defaults and overrides determine the effective values for the resolved mode.\u003C/p\u003E\n\u003Cp\u003EFor implementation details and inspection of effective options, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-configuration-safety\u0022\u003E26. Validation and Configuration Safety\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth Server registers multiple options validators and enables startup validation.\u003C/p\u003E\n\u003Cp\u003EThese validators cover areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EPKCE.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EIdentifier configuration.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server rejects an invalid session lifetime under the applicable validation rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-base-path\u0022\u003EExample: Invalid Base Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe general server validator rejects paths containing \u003Ccode\u003E//\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current validator rejects explicitly enabling \u003Ccode\u003EPureJwt\u003C/code\u003E because that mode is not yet implemented as a supported server mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-limitations\u0022\u003EValidation Limitations\u003C/h3\u003E\n\u003Cp\u003EStartup validation protects the configured options according to the registered validators.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that every custom service implementation or mode-specific override is secure.\u003C/p\u003E\n\u003Cp\u003EWhen using advanced customization, test the resulting effective configuration and authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-configuration-example\u0022\u003E27. Complete Configuration Example\u003C/h2\u003E\n\u003Cp\u003EThe following example combines several commonly used server settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Authentication modes\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n\n // Login security\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.IncludeFailureDetails = false;\n\n // Sessions\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n\n // Token identity\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n\n // Hybrid-specific token lifetime\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n });\n\n // PKCE\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n\n // Hub deployment\n options.HubDeploymentMode = UAuthHubDeploymentMode.Integrated;\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n\n // Credential security\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n\n // Credential recovery\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(30);\n options.ResetCredential.MaxAttempts = 3;\n\n // Identifier login rules\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n\n // Session transport\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableQuery = false;\n\n // Endpoint exposure\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Credentials = true;\n options.Endpoints.Authorization = true;\n\n // Pagination\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n\n // Diagnostics\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an illustrative configuration, not a universal production preset.\u003C/p\u003E\n\u003Cp\u003EApplications should choose session lifetimes, authentication modes, credential transport, tenant behavior, and endpoint exposure according to their own deployment and security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-configuration-mistakes\u0022\u003E28. Common Configuration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-core-options-separately\u0022\u003EConfiguring Core Options Separately\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, configure Core-defined options through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate \u003Ccode\u003EUAuthOptions\u003C/code\u003E configuration does not automatically override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-allowedmodes-selects-the-mode\u0022\u003EAssuming AllowedModes Selects the Mode\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction.\u003C/p\u003E\n\u003Cp\u003EMode selection is performed by the authentication mode resolver.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ignoring-mode-defaults\u0022\u003EIgnoring Mode Defaults\u003C/h3\u003E\n\u003Cp\u003EMode-specific defaults can replace globally configured values.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for explicit mode-specific behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enabling-unsupported-modes\u0022\u003EEnabling Unsupported Modes\u003C/h3\u003E\n\u003Cp\u003EAn authentication mode appearing in an enum does not guarantee runtime support.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-reserved-session-options-as-enforced-security-features\u0022\u003ETreating Reserved Session Options as Enforced Security Features\u003C/h3\u003E\n\u003Cp\u003ESome device and session-chain options are reserved for future enforcement.\u003C/p\u003E\n\u003Cp\u003EVerify actual runtime support before relying on them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022exposing-unnecessary-endpoints\u0022\u003EExposing Unnecessary Endpoints\u003C/h3\u003E\n\u003Cp\u003EDisable endpoint groups or actions that the application does not require.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022allowing-query-string-credentials-without-a-specific-need\u0022\u003EAllowing Query-String Credentials Without a Specific Need\u003C/h3\u003E\n\u003Cp\u003EQuery-string credentials can leak through URLs and infrastructure logs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022weakening-cookie-security\u0022\u003EWeakening Cookie Security\u003C/h3\u003E\n\u003Cp\u003EDo not relax secure transport or cookie protections without understanding the consequences.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-configuration-validation-covers-everything\u0022\u003EAssuming Configuration Validation Covers Everything\u003C/h3\u003E\n\u003Cp\u003EStartup validation does not replace testing, infrastructure security, or validation of custom extension implementations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-quick-reference\u0022\u003E29. Server Options Quick Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConfiguration group\u003C/th\u003E\n\u003Cth\u003ETypical use\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession expiration and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance and lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EQuery page limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint exposure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential source precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie security and scope\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment topology\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUI/API primary credential selection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential delivery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier eligibility\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMulti-profile capability\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication redirects\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EConfigureMode()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMode-specific option overrides\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdvanced endpoint customization\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the configuration architecture and precedence.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, mode resolution, and extension points.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage sessions through the client.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Use the client authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "register-ultimateauth-server", @@ -323,6 +323,11 @@ "Text": "Identifier Behavior", "Level": 1 }, + { + "Id": "identifier-limits", + "Text": "Identifier Limits", + "Level": 1 + }, { "Id": "user-profile-options", "Text": "18. User Profile Options", @@ -539,6 +544,6 @@ "Level": 0 } ], - "SourceHash": "35AE1CE661A0DCC3570C2FF5F8F104352B0D607D3B3BDB3C2286C0D9B78052EC", + "SourceHash": "01AEC87897DF9AAC89F96EEC5B097505FB71A6F6140A7706A099932C15092C36", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json index a2d1d0fc..e1c3c2ec 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json @@ -132,24 +132,31 @@ "Group": "plugin-domains", "GroupOrder": 5 }, + { + "Title": "Profile Validation \u0026 Limits", + "Slug": "plugin-domains/profile-validation-limits", + "Order": 3, + "Group": "plugin-domains", + "GroupOrder": 5 + }, { "Title": "Credentials", "Slug": "plugin-domains/credentials", - "Order": 3, + "Order": 4, "Group": "plugin-domains", "GroupOrder": 5 }, { "Title": "Authorization", "Slug": "plugin-domains/authorization", - "Order": 4, + "Order": 5, "Group": "plugin-domains", "GroupOrder": 5 }, { "Title": "Policies \u0026 Access Control", "Slug": "plugin-domains/policies", - "Order": 5, + "Order": 6, "Group": "plugin-domains", "GroupOrder": 5 }, @@ -265,6 +272,20 @@ "Group": "security", "GroupOrder": 8 }, + { + "Title": "Password Policy \u0026 History", + "Slug": "security/password-policy", + "Order": 6, + "Group": "security", + "GroupOrder": 8 + }, + { + "Title": "Rate Limiting", + "Slug": "security/rate-limiting", + "Order": 8, + "Group": "security", + "GroupOrder": 8 + }, { "Title": "How-to Guides", "Slug": "how-to-guides/index", @@ -299,5 +320,19 @@ "Order": 2, "Group": "migration", "GroupOrder": 10 + }, + { + "Title": "Migrating to v0.1.4", + "Slug": "migration/v0.1.4", + "Order": 3, + "Group": "migration", + "GroupOrder": 10 + }, + { + "Title": "Migrating to v0.2.0", + "Slug": "migration/v0.2.0", + "Order": 4, + "Group": "migration", + "GroupOrder": 10 } ] \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/fundamentals/flow-based-auth.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/fundamentals/flow-based-auth.json index 6586b2fa..9170ffd7 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/fundamentals/flow-based-auth.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/fundamentals/flow-based-auth.json @@ -1,7 +1,7 @@ { "Slug": "fundamentals/flow-based-auth", "Title": "Flow-Based Authentication", - "Html": "\n\u003Cp\u003EUltimateAuth organizes authentication around explicit, controlled operations called \u003Cstrong\u003Eauthentication flows\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EA flow represents an authentication intention, such as establishing access, continuing an existing authentication context, or terminating it.\u003C/p\u003E\n\u003Cp\u003ERather than allowing individual endpoints or client applications to independently decide how authentication should work, UltimateAuth evaluates each operation within a consistent, server-controlled security context.\u003C/p\u003E\n\u003Cp\u003EThis approach is independent of the credential format.\u003C/p\u003E\n\u003Cp\u003EA flow may work with a server-side session, an opaque access token, a JWT, or a refresh token. The effective authentication mode determines which credentials and validation rules apply.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFlow-based authentication is not an alternative to session-based or token-based authentication. It is the architecture that coordinates those strategies.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-authentication-flows-matter\u0022\u003EWhy authentication flows matter\u003C/h2\u003E\n\u003Cp\u003EAuthentication involves more than issuing and validating credentials.\u003C/p\u003E\n\u003Cp\u003EA complete authentication operation may need to determine:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhich tenant the operation belongs to.\u003C/li\u003E\n\u003Cli\u003EWhich authentication mode is permitted.\u003C/li\u003E\n\u003Cli\u003EWhich client profile and runtime characteristics apply.\u003C/li\u003E\n\u003Cli\u003EWhat authentication proof is required.\u003C/li\u003E\n\u003Cli\u003EWhether an existing authentication context can continue.\u003C/li\u003E\n\u003Cli\u003EWhich security policies must be enforced.\u003C/li\u003E\n\u003Cli\u003EWhether server-side state must be created, updated, or revoked.\u003C/li\u003E\n\u003Cli\u003EWhich credentials or other results should be returned to the client.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese decisions should not be independently reconstructed by every endpoint, client SDK, or authentication service.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth establishes a shared context for the operation and delegates the relevant decisions to the components responsible for them.\u003C/p\u003E\n\u003Cp\u003EThe result is a predictable authentication process whose behavior can vary by mode without changing its fundamental security boundaries.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-is-an-authentication-flow\u0022\u003EWhat is an authentication flow?\u003C/h2\u003E\n\u003Cp\u003EAn authentication flow is a controlled operation with a defined purpose, applicable security requirements, and an explicit outcome.\u003C/p\u003E\n\u003Cp\u003EThree primary flows illustrate the model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login\u0022\u003ELogin\u003C/h3\u003E\n\u003Cp\u003ELogin establishes a new authenticated context after the required authentication proof has been accepted.\u003C/p\u003E\n\u003Cp\u003EDepending on the effective mode, the operation may create server-side authentication state, issue access credentials, or perform both actions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh\u0022\u003ERefresh\u003C/h3\u003E\n\u003Cp\u003ERefresh attempts to continue an existing authenticated context without requiring the original authentication proof again.\u003C/p\u003E\n\u003Cp\u003EIts behavior depends on the mode.\u003C/p\u003E\n\u003Cp\u003EFor example, PureOpaque can continue a session through touch semantics, while token-based modes may issue replacement credentials.\u003C/p\u003E\n\u003Cp\u003ERefresh must not bypass expiration, revocation, or other continuation requirements.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003ELogout\u003C/h3\u003E\n\u003Cp\u003ELogout terminates or discards the applicable authentication context.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, this can involve revoking server-side sessions.\u003C/p\u003E\n\u003Cp\u003EIn stateless JWT authentication, client-side credential removal does not automatically invalidate an already issued access token.\u003C/p\u003E\n\u003Cp\u003EThese operations share architectural boundaries but do not perform identical work.\u003C/p\u003E\n\u003Cp\u003EFor their implementation details, see \u003Ca href=\u0022/docs/auth-flows/overview\u0022\u003EAuth Flows\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-flow-execution-model\u0022\u003EThe flow execution model\u003C/h2\u003E\n\u003Cp\u003EAt a conceptual level, an authentication flow follows a common progression:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EAuthentication Intent\n |\n v\nResolve Effective Security Context\n |\n v\nValidate Required Evidence\n |\n v\nEvaluate Applicable Security Rules\n |\n v\nExecute Mode-Specific Operation\n |\n v\nProduce Authentication Outcome\n |\n v\nDeliver Result to Client\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis diagram represents responsibilities, not a fixed implementation sequence.\u003C/p\u003E\n\u003Cp\u003EFor example, credential validation, authority decisions, and state transitions may involve different components or ordering depending on the operation.\u003C/p\u003E\n\u003Cp\u003ESome flows create sessions. Others update existing state, rotate tokens, revoke credentials, or perform no persistent state change.\u003C/p\u003E\n\u003Cp\u003EThe important guarantee is that each operation executes within a controlled context and follows the security rules applicable to its purpose and authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authflowcontext\u0022\u003EAuthFlowContext\u003C/h2\u003E\n\u003Cp\u003EA central concept in this architecture is \u003Ccode\u003EAuthFlowContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E represents the effective authentication environment in which an operation executes.\u003C/p\u003E\n\u003Cp\u003EIt brings together the information required to apply consistent authentication behavior, including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe intended authentication flow.\u003C/li\u003E\n\u003Cli\u003EThe effective authentication mode.\u003C/li\u003E\n\u003Cli\u003EThe resolved tenant context.\u003C/li\u003E\n\u003Cli\u003EThe applicable client profile.\u003C/li\u003E\n\u003Cli\u003EEffective server options and security policies.\u003C/li\u003E\n\u003Cli\u003ERelevant device and session context, when applicable.\u003C/li\u003E\n\u003Cli\u003ECredential delivery and response behavior.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe context is established through framework-controlled boundaries.\u003C/p\u003E\n\u003Cp\u003EOnce established, it must remain stable throughout the operation. Individual services and endpoints must not independently replace it or reconstruct it using different assumptions.\u003C/p\u003E\n\u003Cp\u003EThis matters particularly in applications serving multiple tenants, client runtimes, and authentication modes.\u003C/p\u003E\n\u003Cp\u003EA flow must not begin under one security policy and complete under another merely because different components interpreted the request differently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022context-is-not-authorization\u0022\u003EContext is not authorization\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E describes the environment and rules under which an operation executes.\u003C/p\u003E\n\u003Cp\u003EIts existence does not mean that the operation has been authorized or that the presented credentials are valid.\u003C/p\u003E\n\u003Cp\u003EThe relevant authentication evidence and security requirements must still be evaluated.\u003C/p\u003E\n\u003Cp\u003EAfter authentication has established trusted identity, authorization can operate through the appropriate \u003Ccode\u003EAccessContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe distinction between these contexts is explained in \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flows-and-authentication-modes\u0022\u003EFlows and authentication modes\u003C/h2\u003E\n\u003Cp\u003EAuthentication flows describe \u003Cstrong\u003Ewhat operation is being performed\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EAuthentication modes determine \u003Cstrong\u003Ehow the operation establishes or continues trust\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EConsider login.\u003C/p\u003E\n\u003Cp\u003EIn PureOpaque, a successful login establishes a server-side session and provides a session credential.\u003C/p\u003E\n\u003Cp\u003EIn Hybrid, login establishes server-side session authority and issues separate opaque access and refresh tokens.\u003C/p\u003E\n\u003Cp\u003EIn SemiHybrid, login establishes the session lifecycle state required by the mode and issues JWT-based access credentials.\u003C/p\u003E\n\u003Cp\u003EIn PureJwt, login issues JWT credentials without requiring a server-side session.\u003C/p\u003E\n\u003Cp\u003EAll four are login operations, but their persistence and credential-issuance requirements differ.\u003C/p\u003E\n\u003Cp\u003EThe same principle applies to refresh and logout.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EFlow\u003C/th\u003E\n\u003Cth\u003EWhat remains consistent\u003C/th\u003E\n\u003Cth\u003EWhat varies by mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin\u003C/td\u003E\n\u003Ctd\u003EEstablish authenticated access from validated evidence\u003C/td\u003E\n\u003Ctd\u003ESession creation, token format, credential issuance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh\u003C/td\u003E\n\u003Ctd\u003EDetermine whether authentication may continue\u003C/td\u003E\n\u003Ctd\u003ESession touch, session validation, token rotation, renewal state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout\u003C/td\u003E\n\u003Ctd\u003EEnd or discard the applicable authentication context\u003C/td\u003E\n\u003Ctd\u003ESession revocation, refresh-token invalidation, client credential cleanup\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest authentication\u003C/td\u003E\n\u003Ctd\u003EEstablish trusted identity from presented credentials\u003C/td\u003E\n\u003Ctd\u003ESession validation, opaque token resolution, JWT validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA flow must never introduce a session dependency merely because another authentication mode requires one.\u003C/p\u003E\n\u003Cp\u003EIn particular, PureJwt must remain independent of mandatory session persistence and validation.\u003C/p\u003E\n\u003Cp\u003EFor the complete mode definitions and their guarantees, see \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-controlled-decisions\u0022\u003EServer-controlled decisions\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats authentication as a server-controlled process.\u003C/p\u003E\n\u003Cp\u003EThe client can request an operation and provide the evidence needed to perform it.\u003C/p\u003E\n\u003Cp\u003EHowever, the client does not independently determine:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the authentication evidence is acceptable.\u003C/li\u003E\n\u003Cli\u003EWhich security policies can be bypassed.\u003C/li\u003E\n\u003Cli\u003EWhether an expired or revoked authentication context may continue.\u003C/li\u003E\n\u003Cli\u003EWhether a new session or token may be issued.\u003C/li\u003E\n\u003Cli\u003EWhether a particular authentication mode is permitted.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese decisions belong to trusted framework and application components operating under the effective server configuration.\u003C/p\u003E\n\u003Cp\u003EThis does not mean every authentication decision requires a database lookup.\u003C/p\u003E\n\u003Cp\u003EFor stateless JWT authentication, the server can establish identity through cryptographic verification and the applicable token-validation rules.\u003C/p\u003E\n\u003Cp\u003EFor session-authoritative authentication, the server must also validate the required session state.\u003C/p\u003E\n\u003Cp\u003EServer authority describes \u003Cstrong\u003Ewho controls the rules\u003C/strong\u003E, not whether every request must consult persistent storage.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022separation-of-responsibilities\u0022\u003ESeparation of responsibilities\u003C/h2\u003E\n\u003Cp\u003EFlow-based authentication is also a separation-of-concerns model.\u003C/p\u003E\n\u003Cp\u003EThe components participating in an authentication operation have distinct responsibilities.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ERequest and context resolution\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EEstablish the trusted execution environment, including tenant, mode, client profile, and effective policies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication validation\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EEvaluate the evidence required by the operation and authentication mode.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESecurity decisions\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EDetermine whether the requested operation is permitted under the applicable rules and authoritative state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EOrchestration\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECoordinate the services and state transitions needed to complete the operation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPersistence and credential issuance\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECreate, update, or revoke the relevant authentication state and credentials when required.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EResponse delivery\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECommunicate the result through the appropriate cookie, header, redirect, or other supported delivery mechanism.\u003C/p\u003E\n\u003Cp\u003ESeparating these responsibilities prevents transport behavior from becoming authentication policy.\u003C/p\u003E\n\u003Cp\u003EFor example, whether an access token is delivered through a cookie or header should not change the security requirements used to issue it.\u003C/p\u003E\n\u003Cp\u003EThe technical components and request pipeline implementing these responsibilities are documented in \u003Ca href=\u0022../auth-flows/\u0022\u003EAuth Flows\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flows-across-client-runtimes\u0022\u003EFlows across client runtimes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports different application environments through a common authentication architecture.\u003C/p\u003E\n\u003Cp\u003EA browser application, Blazor Server application, Blazor WebAssembly application, mobile client, and resource API may interact with authentication differently.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA browser application may rely on secure cookies.\u003C/li\u003E\n\u003Cli\u003EA mobile application may manage credentials through secure device storage.\u003C/li\u003E\n\u003Cli\u003EA resource API may validate bearer access tokens.\u003C/li\u003E\n\u003Cli\u003EA Blazor Server application may use a server-controlled session without managing refresh-token rotation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese differences affect client integration and credential transport.\u003C/p\u003E\n\u003Cp\u003EThey do not require independent implementations of the fundamental authentication model.\u003C/p\u003E\n\u003Cp\u003EClient Profiles provide runtime-appropriate behavior and defaults, while authentication modes define the validation and lifecycle strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/client-profiles\u0022\u003EClient Profiles\u003C/a\u003E for how runtime characteristics influence authentication configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022predictable-outcomes\u0022\u003EPredictable outcomes\u003C/h2\u003E\n\u003Cp\u003EAn authentication flow should produce an explicit result that reflects what the operation actually accomplished.\u003C/p\u003E\n\u003Cp\u003EFor example, a refresh operation may:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EContinue an existing session without changing persistent state.\u003C/li\u003E\n\u003Cli\u003EUpdate session activity or expiration when permitted.\u003C/li\u003E\n\u003Cli\u003ERotate refresh credentials and issue replacement tokens.\u003C/li\u003E\n\u003Cli\u003EReject continuation and require reauthentication.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese outcomes are not interchangeable.\u003C/p\u003E\n\u003Cp\u003EA successful operation should not imply that a token was issued when no token was needed.\u003C/p\u003E\n\u003Cp\u003ELikewise, an unsuccessful refresh must not silently create a new authenticated context or bypass the original authentication requirements.\u003C/p\u003E\n\u003Cp\u003EExplicit outcomes allow the client SDK and server components to respond consistently without assuming that every mode behaves the same way.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extensibility-and-security-boundaries\u0022\u003EExtensibility and security boundaries\u003C/h2\u003E\n\u003Cp\u003EA flow-based architecture provides controlled extension points for authentication behavior.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported features and configured services, applications may integrate additional requirements such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMulti-factor authentication.\u003C/li\u003E\n\u003Cli\u003EAdditional credential verification.\u003C/li\u003E\n\u003Cli\u003ETenant-specific authentication policies.\u003C/li\u003E\n\u003Cli\u003EDevice-related security checks.\u003C/li\u003E\n\u003Cli\u003ECustom authentication proof providers.\u003C/li\u003E\n\u003Cli\u003EAdditional security decisions before issuing or continuing access.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EExtensions must preserve the security guarantees of the effective authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor example, a custom flow must not bypass mandatory session validation in Hybrid.\u003C/p\u003E\n\u003Cp\u003ESimilarly, an extension must not make session persistence an undocumented prerequisite for PureJwt.\u003C/p\u003E\n\u003Cp\u003EFlexibility must not silently change the meaning of an authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flow-based-does-not-mean-session-based\u0022\u003EFlow-based does not mean session-based\u003C/h2\u003E\n\u003Cp\u003EAn important distinction is that authentication flows and authentication lineage are related but separate concepts.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication flows\u003C/strong\u003E describe controlled operations.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication lineage\u003C/strong\u003E describes the Root, Chain, and Session relationships used by session-backed authentication.\u003C/p\u003E\n\u003Cp\u003EA flow may operate on that lineage when required.\u003C/p\u003E\n\u003Cp\u003EHowever, not every flow or mode requires persisted Root, Chain, or Session state.\u003C/p\u003E\n\u003Cp\u003EThis separation allows UltimateAuth to support both stateful and stateless authentication without maintaining two unrelated authentication architectures.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E for the lineage model and its role in authentication.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022summary\u0022\u003ESummary\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses authentication flows to coordinate security-sensitive operations through a consistent, trusted execution context.\u003C/p\u003E\n\u003Cp\u003EA flow:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERepresents an explicit authentication intention.\u003C/li\u003E\n\u003Cli\u003EOperates under a resolved and stable \u003Ccode\u003EAuthFlowContext\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EApplies the validation and security rules required by the effective mode.\u003C/li\u003E\n\u003Cli\u003ECoordinates only the state transitions and credential operations necessary for its purpose.\u003C/li\u003E\n\u003Cli\u003EProduces an explicit outcome.\u003C/li\u003E\n\u003Cli\u003EDelivers that outcome according to the applicable client and response configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe architecture does not require every mode to use the same credentials, persistence model, or validation strategy.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFlows define the operation. Modes define its authentication behavior. Client Profiles adapt its integration with the runtime.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003ENext step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E to understand how PureOpaque, Hybrid, SemiHybrid, and PureJwt implement different authentication and session lifecycle strategies.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth organizes authentication around explicit, controlled operations called \u003Cstrong\u003Eauthentication flows\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EA flow represents an authentication intention, such as establishing access, continuing an existing authentication context, or terminating it.\u003C/p\u003E\n\u003Cp\u003ERather than allowing individual endpoints or client applications to independently decide how authentication should work, UltimateAuth evaluates each operation within a consistent, server-controlled security context.\u003C/p\u003E\n\u003Cp\u003EThis approach is independent of the credential format.\u003C/p\u003E\n\u003Cp\u003EA flow may work with a server-side session, an opaque access token, a JWT, or a refresh token. The effective authentication mode determines which credentials and validation rules apply.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFlow-based authentication is not an alternative to session-based or token-based authentication. It is the architecture that coordinates those strategies.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-authentication-flows-matter\u0022\u003EWhy authentication flows matter\u003C/h2\u003E\n\u003Cp\u003EAuthentication involves more than issuing and validating credentials.\u003C/p\u003E\n\u003Cp\u003EA complete authentication operation may need to determine:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhich tenant the operation belongs to.\u003C/li\u003E\n\u003Cli\u003EWhich authentication mode is permitted.\u003C/li\u003E\n\u003Cli\u003EWhich client profile and runtime characteristics apply.\u003C/li\u003E\n\u003Cli\u003EWhat authentication proof is required.\u003C/li\u003E\n\u003Cli\u003EWhether an existing authentication context can continue.\u003C/li\u003E\n\u003Cli\u003EWhich security policies must be enforced.\u003C/li\u003E\n\u003Cli\u003EWhether server-side state must be created, updated, or revoked.\u003C/li\u003E\n\u003Cli\u003EWhich credentials or other results should be returned to the client.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese decisions should not be independently reconstructed by every endpoint, client SDK, or authentication service.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth establishes a shared context for the operation and delegates the relevant decisions to the components responsible for them.\u003C/p\u003E\n\u003Cp\u003EThe result is a predictable authentication process whose behavior can vary by mode without changing its fundamental security boundaries.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-is-an-authentication-flow\u0022\u003EWhat is an authentication flow?\u003C/h2\u003E\n\u003Cp\u003EAn authentication flow is a controlled operation with a defined purpose, applicable security requirements, and an explicit outcome.\u003C/p\u003E\n\u003Cp\u003EThree primary flows illustrate the model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login\u0022\u003ELogin\u003C/h3\u003E\n\u003Cp\u003ELogin establishes a new authenticated context after the required authentication proof has been accepted.\u003C/p\u003E\n\u003Cp\u003EDepending on the effective mode, the operation may create server-side authentication state, issue access credentials, or perform both actions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh\u0022\u003ERefresh\u003C/h3\u003E\n\u003Cp\u003ERefresh attempts to continue an existing authenticated context without requiring the original authentication proof again.\u003C/p\u003E\n\u003Cp\u003EIts behavior depends on the mode.\u003C/p\u003E\n\u003Cp\u003EFor example, PureOpaque can continue a session through touch semantics, while token-based modes may issue replacement credentials.\u003C/p\u003E\n\u003Cp\u003ERefresh must not bypass expiration, revocation, or other continuation requirements.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003ELogout\u003C/h3\u003E\n\u003Cp\u003ELogout terminates or discards the applicable authentication context.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, this can involve revoking server-side sessions.\u003C/p\u003E\n\u003Cp\u003EIn stateless JWT authentication, client-side credential removal does not automatically invalidate an already issued access token.\u003C/p\u003E\n\u003Cp\u003EThese operations share architectural boundaries but do not perform identical work.\u003C/p\u003E\n\u003Cp\u003EFor their implementation details, see \u003Ca href=\u0022/docs/auth-flows/overview\u0022\u003EAuth Flows\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-flow-execution-model\u0022\u003EThe flow execution model\u003C/h2\u003E\n\u003Cp\u003EAt a conceptual level, an authentication flow follows a common progression:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EAuthentication Intent\n |\n v\nResolve Effective Security Context\n |\n v\nValidate Required Evidence\n |\n v\nEvaluate Applicable Security Rules\n |\n v\nExecute Mode-Specific Operation\n |\n v\nProduce Authentication Outcome\n |\n v\nDeliver Result to Client\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis diagram represents responsibilities, not a fixed implementation sequence.\u003C/p\u003E\n\u003Cp\u003EFor example, credential validation, authority decisions, and state transitions may involve different components or ordering depending on the operation.\u003C/p\u003E\n\u003Cp\u003ESome flows create sessions. Others update existing state, rotate tokens, revoke credentials, or perform no persistent state change.\u003C/p\u003E\n\u003Cp\u003EThe important guarantee is that each operation executes within a controlled context and follows the security rules applicable to its purpose and authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authflowcontext\u0022\u003EAuthFlowContext\u003C/h2\u003E\n\u003Cp\u003EA central concept in this architecture is \u003Ccode\u003EAuthFlowContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E represents the effective authentication environment in which an operation executes.\u003C/p\u003E\n\u003Cp\u003EIt brings together the information required to apply consistent authentication behavior, including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe intended authentication flow.\u003C/li\u003E\n\u003Cli\u003EThe effective authentication mode.\u003C/li\u003E\n\u003Cli\u003EThe resolved tenant context.\u003C/li\u003E\n\u003Cli\u003EThe applicable client profile.\u003C/li\u003E\n\u003Cli\u003EEffective server options and security policies.\u003C/li\u003E\n\u003Cli\u003ERelevant device and session context, when applicable.\u003C/li\u003E\n\u003Cli\u003ECredential delivery and response behavior.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe context is established through framework-controlled boundaries.\u003C/p\u003E\n\u003Cp\u003EOnce established, it must remain stable throughout the operation. Individual services and endpoints must not independently replace it or reconstruct it using different assumptions.\u003C/p\u003E\n\u003Cp\u003EThis matters particularly in applications serving multiple tenants, client runtimes, and authentication modes.\u003C/p\u003E\n\u003Cp\u003EA flow must not begin under one security policy and complete under another merely because different components interpreted the request differently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022context-is-not-authorization\u0022\u003EContext is not authorization\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAuthFlowContext\u003C/code\u003E describes the environment and rules under which an operation executes.\u003C/p\u003E\n\u003Cp\u003EIts existence does not mean that the operation has been authorized or that the presented credentials are valid.\u003C/p\u003E\n\u003Cp\u003EThe relevant authentication evidence and security requirements must still be evaluated.\u003C/p\u003E\n\u003Cp\u003EAfter authentication has established trusted identity, authorization can operate through the appropriate \u003Ccode\u003EAccessContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe distinction between these contexts is explained in \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flows-and-authentication-modes\u0022\u003EFlows and authentication modes\u003C/h2\u003E\n\u003Cp\u003EAuthentication flows describe \u003Cstrong\u003Ewhat operation is being performed\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EAuthentication modes determine \u003Cstrong\u003Ehow the operation establishes or continues trust\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EConsider login.\u003C/p\u003E\n\u003Cp\u003EIn PureOpaque, a successful login establishes a server-side session and provides a session credential.\u003C/p\u003E\n\u003Cp\u003EIn Hybrid, login establishes server-side session authority and issues separate opaque access and refresh tokens.\u003C/p\u003E\n\u003Cp\u003EIn SemiHybrid, login establishes the session lifecycle state required by the mode and issues JWT-based access credentials.\u003C/p\u003E\n\u003Cp\u003EIn PureJwt, login issues JWT credentials without requiring a server-side session.\u003C/p\u003E\n\u003Cp\u003EAll four are login operations, but their persistence and credential-issuance requirements differ.\u003C/p\u003E\n\u003Cp\u003EThe same principle applies to refresh and logout.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EFlow\u003C/th\u003E\n\u003Cth\u003EWhat remains consistent\u003C/th\u003E\n\u003Cth\u003EWhat varies by mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin\u003C/td\u003E\n\u003Ctd\u003EEstablish authenticated access from validated evidence\u003C/td\u003E\n\u003Ctd\u003ESession creation, token format, credential issuance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh\u003C/td\u003E\n\u003Ctd\u003EDetermine whether authentication may continue\u003C/td\u003E\n\u003Ctd\u003ESession touch, session validation, token rotation, renewal state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout\u003C/td\u003E\n\u003Ctd\u003EEnd or discard the applicable authentication context\u003C/td\u003E\n\u003Ctd\u003ESession revocation, refresh-token invalidation, client credential cleanup\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest authentication\u003C/td\u003E\n\u003Ctd\u003EEstablish trusted identity from presented credentials\u003C/td\u003E\n\u003Ctd\u003ESession validation, opaque token resolution, JWT validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EA flow must never introduce a session dependency merely because another authentication mode requires one.\u003C/p\u003E\n\u003Cp\u003EIn particular, PureJwt must remain independent of mandatory session persistence and validation.\u003C/p\u003E\n\u003Cp\u003EFor the complete mode definitions and their guarantees, see \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-controlled-decisions\u0022\u003EServer-controlled decisions\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats authentication as a server-controlled process.\u003C/p\u003E\n\u003Cp\u003EThe client can request an operation and provide the evidence needed to perform it.\u003C/p\u003E\n\u003Cp\u003EHowever, the client does not independently determine:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the authentication evidence is acceptable.\u003C/li\u003E\n\u003Cli\u003EWhich security policies can be bypassed.\u003C/li\u003E\n\u003Cli\u003EWhether an expired or revoked authentication context may continue.\u003C/li\u003E\n\u003Cli\u003EWhether a new session or token may be issued.\u003C/li\u003E\n\u003Cli\u003EWhether a particular authentication mode is permitted.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese decisions belong to trusted framework and application components operating under the effective server configuration.\u003C/p\u003E\n\u003Cp\u003EThis does not mean every authentication decision requires a database lookup.\u003C/p\u003E\n\u003Cp\u003EFor stateless JWT authentication, the server can establish identity through cryptographic verification and the applicable token-validation rules.\u003C/p\u003E\n\u003Cp\u003EFor session-authoritative authentication, the server must also validate the required session state.\u003C/p\u003E\n\u003Cp\u003EServer authority describes \u003Cstrong\u003Ewho controls the rules\u003C/strong\u003E, not whether every request must consult persistent storage.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022separation-of-responsibilities\u0022\u003ESeparation of responsibilities\u003C/h2\u003E\n\u003Cp\u003EFlow-based authentication is also a separation-of-concerns model.\u003C/p\u003E\n\u003Cp\u003EThe components participating in an authentication operation have distinct responsibilities.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ERequest and context resolution\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EEstablish the trusted execution environment, including tenant, mode, client profile, and effective policies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication validation\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EEvaluate the evidence required by the operation and authentication mode.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESecurity decisions\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EDetermine whether the requested operation is permitted under the applicable rules and authoritative state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EOrchestration\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECoordinate the services and state transitions needed to complete the operation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPersistence and credential issuance\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECreate, update, or revoke the relevant authentication state and credentials when required.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EResponse delivery\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECommunicate the result through the appropriate cookie, header, redirect, or other supported delivery mechanism.\u003C/p\u003E\n\u003Cp\u003ESeparating these responsibilities prevents transport behavior from becoming authentication policy.\u003C/p\u003E\n\u003Cp\u003EFor example, whether an access token is delivered through a cookie or header should not change the security requirements used to issue it.\u003C/p\u003E\n\u003Cp\u003EThe technical components and request pipeline implementing these responsibilities are documented in \u003Ca href=\u0022/docs/auth-flows/overview\u0022\u003EAuth Flows\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flows-across-client-runtimes\u0022\u003EFlows across client runtimes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports different application environments through a common authentication architecture.\u003C/p\u003E\n\u003Cp\u003EA browser application, Blazor Server application, Blazor WebAssembly application, mobile client, and resource API may interact with authentication differently.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA browser application may rely on secure cookies.\u003C/li\u003E\n\u003Cli\u003EA mobile application may manage credentials through secure device storage.\u003C/li\u003E\n\u003Cli\u003EA resource API may validate bearer access tokens.\u003C/li\u003E\n\u003Cli\u003EA Blazor Server application may use a server-controlled session without managing refresh-token rotation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese differences affect client integration and credential transport.\u003C/p\u003E\n\u003Cp\u003EThey do not require independent implementations of the fundamental authentication model.\u003C/p\u003E\n\u003Cp\u003EClient Profiles provide runtime-appropriate behavior and defaults, while authentication modes define the validation and lifecycle strategy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/client-profiles\u0022\u003EClient Profiles\u003C/a\u003E for how runtime characteristics influence authentication configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022predictable-outcomes\u0022\u003EPredictable outcomes\u003C/h2\u003E\n\u003Cp\u003EAn authentication flow should produce an explicit result that reflects what the operation actually accomplished.\u003C/p\u003E\n\u003Cp\u003EFor example, a refresh operation may:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EContinue an existing session without changing persistent state.\u003C/li\u003E\n\u003Cli\u003EUpdate session activity or expiration when permitted.\u003C/li\u003E\n\u003Cli\u003ERotate refresh credentials and issue replacement tokens.\u003C/li\u003E\n\u003Cli\u003EReject continuation and require reauthentication.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese outcomes are not interchangeable.\u003C/p\u003E\n\u003Cp\u003EA successful operation should not imply that a token was issued when no token was needed.\u003C/p\u003E\n\u003Cp\u003ELikewise, an unsuccessful refresh must not silently create a new authenticated context or bypass the original authentication requirements.\u003C/p\u003E\n\u003Cp\u003EExplicit outcomes allow the client SDK and server components to respond consistently without assuming that every mode behaves the same way.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extensibility-and-security-boundaries\u0022\u003EExtensibility and security boundaries\u003C/h2\u003E\n\u003Cp\u003EA flow-based architecture provides controlled extension points for authentication behavior.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported features and configured services, applications may integrate additional requirements such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMulti-factor authentication.\u003C/li\u003E\n\u003Cli\u003EAdditional credential verification.\u003C/li\u003E\n\u003Cli\u003ETenant-specific authentication policies.\u003C/li\u003E\n\u003Cli\u003EDevice-related security checks.\u003C/li\u003E\n\u003Cli\u003ECustom authentication proof providers.\u003C/li\u003E\n\u003Cli\u003EAdditional security decisions before issuing or continuing access.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EExtensions must preserve the security guarantees of the effective authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor example, a custom flow must not bypass mandatory session validation in Hybrid.\u003C/p\u003E\n\u003Cp\u003ESimilarly, an extension must not make session persistence an undocumented prerequisite for PureJwt.\u003C/p\u003E\n\u003Cp\u003EFlexibility must not silently change the meaning of an authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022flow-based-does-not-mean-session-based\u0022\u003EFlow-based does not mean session-based\u003C/h2\u003E\n\u003Cp\u003EAn important distinction is that authentication flows and authentication lineage are related but separate concepts.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication flows\u003C/strong\u003E describe controlled operations.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAuthentication lineage\u003C/strong\u003E describes the Root, Chain, and Session relationships used by session-backed authentication.\u003C/p\u003E\n\u003Cp\u003EA flow may operate on that lineage when required.\u003C/p\u003E\n\u003Cp\u003EHowever, not every flow or mode requires persisted Root, Chain, or Session state.\u003C/p\u003E\n\u003Cp\u003EThis separation allows UltimateAuth to support both stateful and stateless authentication without maintaining two unrelated authentication architectures.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E for the lineage model and its role in authentication.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022summary\u0022\u003ESummary\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses authentication flows to coordinate security-sensitive operations through a consistent, trusted execution context.\u003C/p\u003E\n\u003Cp\u003EA flow:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERepresents an explicit authentication intention.\u003C/li\u003E\n\u003Cli\u003EOperates under a resolved and stable \u003Ccode\u003EAuthFlowContext\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EApplies the validation and security rules required by the effective mode.\u003C/li\u003E\n\u003Cli\u003ECoordinates only the state transitions and credential operations necessary for its purpose.\u003C/li\u003E\n\u003Cli\u003EProduces an explicit outcome.\u003C/li\u003E\n\u003Cli\u003EDelivers that outcome according to the applicable client and response configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe architecture does not require every mode to use the same credentials, persistence model, or validation strategy.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EFlows define the operation. Modes define its authentication behavior. Client Profiles adapt its integration with the runtime.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003ENext step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E to understand how PureOpaque, Hybrid, SemiHybrid, and PureJwt implement different authentication and session lifecycle strategies.\u003C/p\u003E\n", "Headings": [ { "Id": "why-authentication-flows-matter", @@ -89,6 +89,6 @@ "Level": 0 } ], - "SourceHash": "6371366D09791BCDFD207180BECD70AE6AE37CEC2D3979ED0510C4F47C56B4AB", + "SourceHash": "3ED10C9D7BF82FBBD7E924E2937D960A350455E2BFD7CF2653256D199D5D3790", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json index 65894848..819aab59 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/real-world-setup", "Title": "Real World Setup", - "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E12. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E13. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E14. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.Password.Level = PasswordPolicyLevel.Strict;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-security-configuration\u0022\u003EPassword Security Configuration\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth uses the Standard password policy by default. Applications can select Basic or Strict, or override individual requirements through \u003Ccode\u003Eoptions.Password\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor the preset comparison, configuration examples, and password-history settings, see \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxy\u0022\u003E12. Client IP Address \u0026amp; Reverse Proxy\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for device information and IP-based rate limiting. It does not directly trust \u003Ccode\u003EX-Forwarded-For\u003C/code\u003E or \u003Ccode\u003ECF-Connecting-IP\u003C/code\u003E headers.\u003C/p\u003E\n\u003Cp\u003EIf your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy.\u003C/p\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E, before \u003Ccode\u003Ebuilder.Build()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing System.Net;\nusing Microsoft.AspNetCore.HttpOverrides;\n\nbuilder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt;\n{\n options.ForwardedHeaders =\n ForwardedHeaders.XForwardedFor |\n ForwardedHeaders.XForwardedProto;\n\n // Replace with your actual Caddy or Nginx proxy IP.\n options.KnownProxies.Add(IPAddress.Parse(\u0026quot;172.20.0.5\u0026quot;));\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen enable the middleware \u003Cstrong\u003Ebefore\u003C/strong\u003E UltimateAuth and HTTPS redirection:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\n\napp.UseHttpsRedirection();\napp.UseUltimateAuthWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe example IP \u003Ccode\u003E172.20.0.5\u003C/code\u003E represents a trusted reverse proxy, not the visitor\u0027s IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required.\u003C/p\u003E\n\u003Cp\u003EFor multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E13. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E14. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Reverse proxy headers are configured correctly if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E15. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "choose-your-architecture", @@ -68,6 +68,11 @@ "Text": "Authentication behavior", "Level": 1 }, + { + "Id": "password-security-configuration", + "Text": "Password Security Configuration", + "Level": 1 + }, { "Id": "uauthhub-dedicated-authentication-server", "Text": "4. UAuthHub \u2014 Dedicated Authentication Server", @@ -278,9 +283,14 @@ "Text": "Configuration files", "Level": 1 }, + { + "Id": "client-ip-address-reverse-proxy", + "Text": "12. Client IP Address \u0026 Reverse Proxy", + "Level": 0 + }, { "Id": "development-and-production-differences", - "Text": "12. Development and Production Differences", + "Text": "13. Development and Production Differences", "Level": 0 }, { @@ -300,12 +310,12 @@ }, { "Id": "deployment-checklist", - "Text": "13. Deployment Checklist", + "Text": "14. Deployment Checklist", "Level": 0 }, { "Id": "choosing-your-architecture", - "Text": "14. Choosing Your Architecture", + "Text": "15. Choosing Your Architecture", "Level": 0 }, { @@ -334,6 +344,6 @@ "Level": 0 } ], - "SourceHash": "770C2ACEAC642791FB3C618D54F7EB1108DF9651ABFBF28CD7D5700EB25EF255", + "SourceHash": "58DC0990FBB207C203C68D304A561D1ED8C87494BD4DEA86C1171568EA6B4466", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/index.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/index.json index c595f397..8f6b4d5a 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/index.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/index.json @@ -1,14 +1,44 @@ { "Slug": "migration/index", "Title": "Migration Guides", - "Html": "\n\u003Cp\u003EVersion-specific instructions for upgrading UltimateAuth applications.\u003C/p\u003E\n\u003Cp\u003EMigration guides focus on breaking changes, behavior changes, and required application updates.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-guides\u0022\u003EAvailable Guides\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/migration/v0.1.1\u0022\u003EMigrating to v0.1.1\u003C/a\u003E \u2014 Blazor route authorization and component lifecycle changes.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EKeep your UltimateAuth applications up to date with clear, version-specific upgrade guidance.\u003C/p\u003E\n\u003Cp\u003EThis page provides an overview of stable UltimateAuth releases, their migration impact, and any actions required when upgrading.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EMost updates require little or no application code changes.\u003C/strong\u003E When an update requires attention, a dedicated migration guide explains what changed and how to adapt your application.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022version-migration-overview\u0022\u003EVersion Migration Overview\u003C/h2\u003E\n\u003Cp\u003EThe table below lists stable releases in descending order, with the newest version first.\u003C/p\u003E\n\u003Cp\u003EPreview and release candidate versions are excluded.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EVersion\u003C/th\u003E\n\u003Cth\u003EMigration Impact\u003C/th\u003E\n\u003Cth\u003EWhat\u0027s Changed\u003C/th\u003E\n\u003Cth\u003EMigration Guide\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.2.0 (In Progress)\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003EMedium (Provisional)\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAsync authorization APIs, authorization checks, and integration changes\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/migration/v0.2.0\u0022\u003EView Guide\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.1.4\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELow\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECSRF protection, antiforgery integration, and interface changes\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/migration/v0.1.4\u0022\u003EView Guide\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.1.3\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003ENone\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESession deletion, revocation, and refresh consistency fixes\u003C/td\u003E\n\u003Ctd\u003ENot required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.1.2\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003ENone\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EPassword change transaction and session revocation fixes\u003C/td\u003E\n\u003Ctd\u003ENot required\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.1.1\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELow\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EBlazor route authorization and component lifecycle changes\u003C/td\u003E\n\u003Ctd\u003E\u003Ca href=\u0022/docs/migration/v0.1.1\u0022\u003EView Guide\u003C/a\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003Ev0.1.0\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Cstrong\u003EN/A\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EInitial stable release\u003C/td\u003E\n\u003Ctd\u003ENot applicable\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EMigration impact describes the expected developer effort and affected integration surface, not the size of the code change or the significance of the release.\u003C/strong\u003E A small change to a widely used authentication API may have a higher migration impact than a large internal refactor.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EIndividual preview and release candidate versions are not listed separately. Migration guidance for an in-progress release may change before its stable release.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cp\u003ESecurity and reliability fixes may have an impact of \u003Cstrong\u003ENone\u003C/strong\u003E while still being strongly recommended.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-migration-impact\u0022\u003EUnderstanding Migration Impact\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EImpact\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003Cth\u003EExpected Action\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ENone\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ENo application code or configuration changes are required for standard integrations.\u003C/td\u003E\n\u003Ctd\u003EUpdate packages and run existing tests.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELow\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESmall, localized changes may be required, typically for specific integrations or customizations.\u003C/td\u003E\n\u003Ctd\u003EReview the migration guide and update affected code.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EMedium\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EChanges affect commonly used APIs, core integration points, or multiple application areas. Even a small code change is classified as Medium when developers must update functionality they use directly and extensively.\u003C/td\u003E\n\u003Ctd\u003EReview the migration guide, update affected integrations, and perform targeted regression testing.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EHigh\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESignificant architectural, API, persistence, or behavioral changes require a planned migration.\u003C/td\u003E\n\u003Ctd\u003EReview all migration steps, test thoroughly, and plan deployment carefully.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EMigration impact is evaluated using both \u003Cstrong\u003Ethe effort required and how directly the changes affect application developers\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EA change does not qualify as Low simply because it requires only a few lines of code. Breaking changes to frequently used authentication, authorization, session management, or client integration APIs may be classified as Medium even when the implementation changes are relatively small.\u003C/p\u003E\n\u003Cp\u003EApplications using only unaffected functionality may require fewer changes than the stated impact level suggests.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-to-upgrade\u0022\u003EHow to Upgrade\u003C/h2\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EIdentify your current version.\u003C/strong\u003E Check the installed \u003Ccode\u003ECodeBeam.UltimateAuth.*\u003C/code\u003E NuGet packages.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EReview every version between your current and target versions.\u003C/strong\u003E Migration requirements are cumulative when skipping releases.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFollow the linked migration guides.\u003C/strong\u003E Apply any required API, configuration, or behavioral changes in version order.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUpdate your UltimateAuth packages together.\u003C/strong\u003E Keep related packages on compatible versions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuild and test your application.\u003C/strong\u003E Verify login, logout, refresh, authorization, and session management flows relevant to your integration.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor releases marked \u003Cstrong\u003ENone\u003C/strong\u003E, no dedicated migration guide is necessary. You should still review the release notes and run your application tests.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-migration-guides\u0022\u003EAvailable Migration Guides\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022migrating-to-v0.1.4\u0022\u003E\u003Ca href=\u0022/docs/migration/v0.1.4\u0022\u003EMigrating to v0.1.4\u003C/a\u003E\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EImpact: Low\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EIntroduces antiforgery protection for cookie-based authentication flows, updates browser client integration, and changes selected session resolution and browser bridge interfaces.\u003C/p\u003E\n\u003Cp\u003EApplications using the standard UltimateAuth browser client generally receive antiforgery handling automatically.\u003C/p\u003E\n\u003Cp\u003ECustom HTTP clients, manually implemented browser bridges, and custom session resolvers should review the guide.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022migrating-to-v0.1.1\u0022\u003E\u003Ca href=\u0022/docs/migration/v0.1.1\u0022\u003EMigrating to v0.1.1\u003C/a\u003E\u003C/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EImpact: Low\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EIntroduces routing-level enforcement for \u003Ccode\u003E[UAuthAuthorize]\u003C/code\u003E and updates the \u003Ccode\u003EUAuthComponentBase\u003C/code\u003E lifecycle.\u003C/p\u003E\n\u003Cp\u003EMost applications using the built-in \u003Ccode\u003EUAuthApp\u003C/code\u003E router require no changes. Applications with custom routers, component lifecycle overrides, or specialized session requirements should review the guide.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022release-history\u0022\u003ERelease History\u003C/h2\u003E\n\u003Cp\u003EFor complete release notes, bug fixes, and security updates, visit the \u003Ca href=\u0022https://github.com/CodeBeamOrg/UltimateAuth/releases\u0022\u003EUltimateAuth GitHub Releases\u003C/a\u003E page.\u003C/p\u003E\n\u003Cp\u003EMigration guides focus on changes developers must make to their applications. Release notes provide the complete history of changes in each version.\u003C/p\u003E\n\u003Chr\u003E\n\u003Cp\u003E\u003Cstrong\u003EUpgrading across multiple versions?\u003C/strong\u003E Start with the earliest version after your currently installed release and review each migration entry through your target version.\u003C/p\u003E\n", "Headings": [ { - "Id": "available-guides", - "Text": "Available Guides", + "Id": "version-migration-overview", + "Text": "Version Migration Overview", + "Level": 0 + }, + { + "Id": "understanding-migration-impact", + "Text": "Understanding Migration Impact", + "Level": 0 + }, + { + "Id": "how-to-upgrade", + "Text": "How to Upgrade", + "Level": 0 + }, + { + "Id": "available-migration-guides", + "Text": "Available Migration Guides", + "Level": 0 + }, + { + "Id": "migrating-to-v0.1.4", + "Text": "Migrating to v0.1.4", + "Level": 1 + }, + { + "Id": "migrating-to-v0.1.1", + "Text": "Migrating to v0.1.1", + "Level": 1 + }, + { + "Id": "release-history", + "Text": "Release History", "Level": 0 } ], - "SourceHash": "FA6FAFE02BA9EF2656EBA1C8531CEC3F43A2666D05A61B6E2979BD96B3593D8B", + "SourceHash": "DD4CBD449E209E19EED5EE80BEE6DEA683168AF0485EA7C6CF76F5D80A8A3B76", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.1.4.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.1.4.json new file mode 100644 index 00000000..fefe0679 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.1.4.json @@ -0,0 +1,54 @@ +{ + "Slug": "migration/v0.1.4", + "Title": "Migrating to v0.1.4", + "Html": "\n\u003Cp\u003E\u003Cstrong\u003EMigration Impact: Low\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth v0.1.4 introduces antiforgery protection for cookie-based authentication flows and updates several client and server integration interfaces.\u003C/p\u003E\n\u003Cp\u003EThe release strengthens protection against Cross-Site Request Forgery (CSRF) while preserving the standard UltimateAuth developer experience.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EMost applications using the built-in UltimateAuth browser client require no application code changes.\u003C/strong\u003E Antiforgery token handling is integrated into the client.\u003C/p\u003E\n\u003Cp\u003EApplications using custom HTTP requests, custom browser bridges, or custom session resolvers should review the changes below.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022antiforgery-protection-for-cookie-based-authentication\u0022\u003E1. Antiforgery Protection for Cookie-Based Authentication\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth now validates antiforgery tokens for protected operations when cookie-based authentication is applicable.\u003C/p\u003E\n\u003Cp\u003EThis includes relevant authentication operations and authenticated state-changing endpoints.\u003C/p\u003E\n\u003Cp\u003ERequests that fail antiforgery validation are rejected with HTTP \u003Ccode\u003E400 Bad Request\u003C/code\u003E and the following error:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;error\u0026quot;: \u0026quot;invalid_antiforgery_token\u0026quot;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-the-built-in-browser-client\u0022\u003EUsing the built-in browser client\u003C/h3\u003E\n\u003Cp\u003EThe UltimateAuth browser client now supports retrieving and submitting antiforgery tokens automatically.\u003C/p\u003E\n\u003Cp\u003EIf your application uses the standard UltimateAuth client integration, no additional antiforgery implementation is normally required.\u003C/p\u003E\n\u003Cp\u003EMake sure the server and client packages are updated together.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-custom-http-requests\u0022\u003EUsing custom HTTP requests\u003C/h3\u003E\n\u003Cp\u003EIf your application calls protected UltimateAuth endpoints directly from a browser, you must obtain an antiforgery token and submit it with the request.\u003C/p\u003E\n\u003Cp\u003EThe default token endpoint is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EHTTP\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-http\u0022\u003EGET /auth/antiforgery/token\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;requestToken\u0026quot;: \u0026quot;...\u0026quot;,\n \u0026quot;headerName\u0026quot;: \u0026quot;X-UAuth-CSRF\u0026quot;,\n \u0026quot;formFieldName\u0026quot;: \u0026quot;__RequestVerificationToken\u0026quot;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe endpoint also issues an antiforgery cookie.\u003C/p\u003E\n\u003Cp\u003EFor subsequent protected requests:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EObtain a token from the configured antiforgery endpoint.\u003C/li\u003E\n\u003Cli\u003EPreserve the associated antiforgery cookie.\u003C/li\u003E\n\u003Cli\u003ESend the request token using the returned \u003Ccode\u003EheaderName\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EInclude the appropriate authentication credentials for the request.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EHTTP\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-http\u0022\u003EPOST /auth/logout\nX-UAuth-CSRF: \u0026lt;request-token\u0026gt;\nCookie: \u0026lt;authentication-and-antiforgery-cookies\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe request token and antiforgery cookie must belong to the same antiforgery context.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not hardcode or fabricate antiforgery tokens.\u003C/strong\u003E Always obtain them from the server.\u003C/p\u003E\n\u003Cp\u003EThe default endpoint path assumes the standard \u003Ccode\u003E/auth\u003C/code\u003E base path. Applications using custom routing or multi-tenancy must use their configured endpoint URL.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-server-side-integrations\u0022\u003ECustom server-side integrations\u003C/h3\u003E\n\u003Cp\u003EApplications using custom HTTP clients or server-to-server integrations should review whether their requests use cookie-based authentication.\u003C/p\u003E\n\u003Cp\u003EAntiforgery requirements depend on the authentication transport and operation. They are not automatically equivalent for every bearer-token or header-based request.\u003C/p\u003E\n\u003Cp\u003EDo not disable antiforgery validation to resolve an integration failure. Update the affected request flow instead.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022isessionidresolver-is-now-asynchronous\u0022\u003E2. ISessionIdResolver Is Now Asynchronous\u003C/h2\u003E\n\u003Cp\u003EThe \u003Ccode\u003EISessionIdResolver\u003C/code\u003E interface has changed to support asynchronous session resolution.\u003C/p\u003E\n\u003Cp\u003EIf you implement this interface yourself, update the implementation.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EBefore (v0.1.3):\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic AuthSessionId? Resolve(HttpContext context)\n{\n // Resolve the session identifier.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAfter (v0.1.4):\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic ValueTask\u0026lt;AuthSessionId?\u0026gt; ResolveAsync(HttpContext context, CancellationToken ct = default)\n{\n ct.ThrowIfCancellationRequested();\n\n // Resolve the session identifier.\n return ValueTask.FromResult\u0026lt;AuthSessionId?\u0026gt;(null);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace the placeholder return with your existing resolution logic.\u003C/p\u003E\n\u003Cp\u003EIf your resolver performs asynchronous operations, use an \u003Ccode\u003Easync ValueTask\u0026lt;AuthSessionId?\u0026gt;\u003C/code\u003E implementation and await those operations.\u003C/p\u003E\n\u003Cp\u003EUpdate any direct calls from \u003Ccode\u003EResolve(...)\u003C/code\u003E to \u003Ccode\u003EResolveAsync(...)\u003C/code\u003E and await the result.\u003C/p\u003E\n\u003Cp\u003EApplications using the built-in session resolver do not need to make this change.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-browser-bridges\u0022\u003E3. Custom Browser Bridges\u003C/h2\u003E\n\u003Cp\u003EThe \u003Ccode\u003EIBrowserUAuthBridge\u003C/code\u003E interface now includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EValueTask ConfigureAntiforgeryAsync(string tokenUrl, string? trustedOrigin = null);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows the UltimateAuth client bootstrapper to configure antiforgery handling for browser requests.\u003C/p\u003E\n\u003Cp\u003EIf you provide a custom implementation of \u003Ccode\u003EIBrowserUAuthBridge\u003C/code\u003E, implement this new method.\u003C/p\u003E\n\u003Cp\u003EYour implementation must configure the browser-side antiforgery integration using the supplied token endpoint and optional trusted origin.\u003C/p\u003E\n\u003Cp\u003EThe exact implementation depends on your browser integration.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not add an empty implementation unless antiforgery handling is already provided by your custom bridge.\u003C/strong\u003E Doing so may leave protected browser requests without the required token.\u003C/p\u003E\n\u003Cp\u003EApplications using the built-in UltimateAuth browser bridge do not need to make this change.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022antiforgery-endpoint-configuration\u0022\u003E4. Antiforgery Endpoint Configuration\u003C/h2\u003E\n\u003Cp\u003Ev0.1.4 introduces a server-side antiforgery configuration section:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Antiforgery.TokenEndpoint = \u0026quot;/antiforgery/token\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe client also exposes the corresponding endpoint setting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Endpoints.AntiforgeryToken = \u0026quot;/antiforgery/token\u0026quot;;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default endpoint path is \u003Ccode\u003E/antiforgery/token\u003C/code\u003E, resolved under the configured UltimateAuth base path.\u003C/p\u003E\n\u003Cp\u003ENo configuration change is required when using the defaults.\u003C/p\u003E\n\u003Cp\u003EIf you customize the endpoint, ensure the server and client configurations match.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022verify-your-application\u0022\u003E5. Verify Your Application\u003C/h2\u003E\n\u003Cp\u003EAfter upgrading to v0.1.4, verify the following flows where applicable:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin and logout using the built-in browser client.\u003C/li\u003E\n\u003Cli\u003ESession refresh and validation.\u003C/li\u003E\n\u003Cli\u003EAuthenticated account and session management operations.\u003C/li\u003E\n\u003Cli\u003ECustom requests to UltimateAuth endpoints.\u003C/li\u003E\n\u003Cli\u003ECustom session resolver implementations.\u003C/li\u003E\n\u003Cli\u003ECustom browser bridge implementations.\u003C/li\u003E\n\u003Cli\u003EExternal Hub and multi-tenant configurations.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor applications using custom browser requests, verify that valid antiforgery tokens allow protected operations and missing or invalid tokens are rejected.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022migration-checklist\u0022\u003EMigration Checklist\u003C/h2\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Update UltimateAuth packages to compatible v0.1.4 versions.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Update custom \u003Ccode\u003EISessionIdResolver\u003C/code\u003E implementations, if any.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Update custom \u003Ccode\u003EIBrowserUAuthBridge\u003C/code\u003E implementations, if any.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Add antiforgery token handling to custom browser HTTP requests, if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Verify custom antiforgery endpoint configuration, if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Run authentication, authorization, and session regression tests.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Chr\u003E\n\u003Cp\u003E\u003Cstrong\u003EUsing the standard UltimateAuth client and built-in session resolution?\u003C/strong\u003E No application code changes are normally required. Update your packages and verify your existing authentication flows.\u003C/p\u003E\n\u003Cp\u003EFor the complete list of changes, see \u003Ca href=\u0022https://github.com/CodeBeamOrg/UltimateAuth/releases/tag/v0.1.4\u0022\u003EUltimateAuth v0.1.4 Release\u003C/a\u003E.\u003C/p\u003E\n", + "Headings": [ + { + "Id": "antiforgery-protection-for-cookie-based-authentication", + "Text": "1. Antiforgery Protection for Cookie-Based Authentication", + "Level": 0 + }, + { + "Id": "using-the-built-in-browser-client", + "Text": "Using the built-in browser client", + "Level": 1 + }, + { + "Id": "using-custom-http-requests", + "Text": "Using custom HTTP requests", + "Level": 1 + }, + { + "Id": "custom-server-side-integrations", + "Text": "Custom server-side integrations", + "Level": 1 + }, + { + "Id": "isessionidresolver-is-now-asynchronous", + "Text": "2. ISessionIdResolver Is Now Asynchronous", + "Level": 0 + }, + { + "Id": "custom-browser-bridges", + "Text": "3. Custom Browser Bridges", + "Level": 0 + }, + { + "Id": "antiforgery-endpoint-configuration", + "Text": "4. Antiforgery Endpoint Configuration", + "Level": 0 + }, + { + "Id": "verify-your-application", + "Text": "5. Verify Your Application", + "Level": 0 + }, + { + "Id": "migration-checklist", + "Text": "Migration Checklist", + "Level": 0 + } + ], + "SourceHash": "F8FA4FDA0E64B56BADDBA75519538C1ACBFDF88567EF487D30011FAB944B67CC", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.2.0.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.2.0.json new file mode 100644 index 00000000..a0206e85 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/migration/v0.2.0.json @@ -0,0 +1,39 @@ +{ + "Slug": "migration/v0.2.0", + "Title": "Migrating to v0.2.0", + "Html": "\n\u003Cp\u003E\u003Cstrong\u003EMigration Impact: Medium \u2014 In Progress\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ELatest published prerelease reviewed: v0.2.0-preview.2\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThis guide covers migration requirements for the upcoming UltimateAuth v0.2.0 release.\u003C/p\u003E\n\u003Cp\u003EThe guide is provisional and will be updated as new preview and release candidate versions become available. The final migration requirements may change before the stable v0.2.0 release.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022asynchronous-authorization-policies\u0022\u003E1. Asynchronous Authorization Policies\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.1\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003ECustom implementations of \u003Ccode\u003EIAccessPolicy\u003C/code\u003E, \u003Ccode\u003EIAccessInvariant\u003C/code\u003E, and \u003Ccode\u003EIAccessAuthority\u003C/code\u003E should migrate to \u003Ccode\u003EDecideAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EBefore:\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic AccessDecision Decide(AccessContext context)\n{\n return AccessDecision.Allow();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAfter:\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic ValueTask\u0026lt;AccessDecision\u0026gt; DecideAsync(AccessContext context, CancellationToken ct = default)\n{\n return ValueTask.FromResult(AccessDecision.Allow());\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor \u003Ccode\u003EIAccessAuthority\u003C/code\u003E, preserve the existing \u003Ccode\u003EruntimePolicies\u003C/code\u003E parameter and add the cancellation token.\u003C/p\u003E\n\u003Cp\u003ESynchronous \u003Ccode\u003EDecide\u003C/code\u003E methods are obsolete. Existing synchronous implementations retain compatibility through the interface defaults, but new implementations should use \u003Ccode\u003EDecideAsync\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EIf your application invokes an authority directly, switch to \u003Ccode\u003EDecideAsync\u003C/code\u003E and await the result.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-check-results\u0022\u003E2. Authorization Check Results\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.1\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ECheckAsync\u003C/code\u003E now returns an explicit authorization decision in the response body.\u003C/p\u003E\n\u003Cp\u003EIf your application previously interpreted HTTP \u003Ccode\u003E403\u003C/code\u003E as the result of a successful authorization evaluation that denied access, update that handling.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Authorization.CheckAsync(request);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { IsAllowed: true })\n{\n // Authorized\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn HTTP-successful authorization check does not necessarily mean that access was granted.\u003C/p\u003E\n\u003Cp\u003EInspect the returned authorization decision rather than relying solely on the HTTP status.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-user-lifecycle-integrations\u0022\u003E3. Custom User Lifecycle Integrations\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.2\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EIUserLifecycleIntegration.OnUserCreatedAsync\u003C/code\u003E signature now includes \u003Ccode\u003EUAuthClientProfile\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EBefore:\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ETask OnUserCreatedAsync(\n TenantKey tenant,\n UserKey userKey,\n object request,\n CancellationToken ct = default);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EAfter:\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ETask OnUserCreatedAsync(\n TenantKey tenant,\n UserKey userKey,\n object request,\n UAuthClientProfile clientProfile,\n CancellationToken ct = default);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUpdate custom implementations to accept the additional client profile parameter.\u003C/p\u003E\n\u003Cp\u003EApplications using built-in lifecycle integrations do not need to make this change.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-and-profile-limits\u0022\u003E4. Identifier and Profile Limits\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.2\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe reference Users domain now enforces additional identifier and profile safety limits.\u003C/p\u003E\n\u003Cp\u003EThe default maximum is 10 active identifiers per user. Applications requiring a different capacity should configure \u003Ccode\u003EIdentifiers.Behavior.MaxIdentifiersPerUser\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EExisting profile creation and update operations must also respect the new field-length and metadata limits.\u003C/p\u003E\n\u003Cp\u003EIf you use EF Core persistence:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EReview existing profile data for values exceeding the new limits.\u003C/li\u003E\n\u003Cli\u003EAdd or apply the appropriate EF Core database migration for your application\u0027s DbContext.\u003C/li\u003E\n\u003Cli\u003EVerify that applications handling profile validation errors use the returned error codes.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/plugin-domains/profile-validation-limits\u0022\u003EProfile Validation \u0026amp; Limits\u003C/a\u003E for the supported limits.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-policies-and-history\u0022\u003E5. Password Policies and History\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.2\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe reference password credential implementation now applies configurable password policies, with Standard as the default level.\u003C/p\u003E\n\u003Cp\u003EReview custom registration, password-change, and password-reset flows to ensure that they handle server-side password validation errors.\u003C/p\u003E\n\u003Cp\u003EApplications using persistent password-history storage must apply the required database migration before enabling password history.\u003C/p\u003E\n\u003Cp\u003EPassword history is disabled by default (\u003Ccode\u003ERememberLast = 0\u003C/code\u003E).\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E for configuration and persistence requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022rate-limiting\u0022\u003E6. Rate Limiting\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EAvailable since preview.2\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EBuilt-in endpoint rate limiting is enabled by default when using:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIf your application already registers its own rate limiting middleware, avoid installing a duplicate limiter.\u003C/p\u003E\n\u003Cp\u003ETo retain application-owned rate limiting:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen configure your existing \u003Ccode\u003EUseRateLimiter()\u003C/code\u003E middleware in the appropriate pipeline position.\u003C/p\u003E\n\u003Cp\u003EApplications using \u003Ccode\u003EUseUltimateAuth()\u003C/code\u003E without the integrated ASP.NET Core helper do not enable UltimateAuth rate limiting by default.\u003C/p\u003E\n\u003Cp\u003EReview custom HTTP clients and authentication flows for HTTP \u003Ccode\u003E429 Too Many Requests\u003C/code\u003E handling.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/rate-limiting\u0022\u003ERate Limiting\u003C/a\u003E for middleware integration and defaults.\u003C/p\u003E\n\u003Chr\u003E\n\u003Cp\u003EThis page will continue to evolve through the v0.2.0 preview and RC series and will become the stable migration guide when v0.2.0 is released.\u003C/p\u003E\n", + "Headings": [ + { + "Id": "asynchronous-authorization-policies", + "Text": "1. Asynchronous Authorization Policies", + "Level": 0 + }, + { + "Id": "authorization-check-results", + "Text": "2. Authorization Check Results", + "Level": 0 + }, + { + "Id": "custom-user-lifecycle-integrations", + "Text": "3. Custom User Lifecycle Integrations", + "Level": 0 + }, + { + "Id": "identifier-and-profile-limits", + "Text": "4. Identifier and Profile Limits", + "Level": 0 + }, + { + "Id": "password-policies-and-history", + "Text": "5. Password Policies and History", + "Level": 0 + }, + { + "Id": "rate-limiting", + "Text": "6. Rate Limiting", + "Level": 0 + } + ], + "SourceHash": "99DF0B4E30A8578E3B2E809B38606FC0041D6A54F05EECDC98D95E8FD78DCBBA", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/authorization.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/authorization.json index a8d97bf2..43d6f13d 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/authorization.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/authorization.json @@ -154,6 +154,6 @@ "Level": 0 } ], - "SourceHash": "E8E9478440BD9758F067833BE9E8597AEE9A25F0870CB9416A4390F8674A8D07", + "SourceHash": "D39724293797A9792171FDEB55D849FA5D3845BF2E1B2413F10EC86A39B92123", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json index 6e4d4195..8cf3676f 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/credentials.json @@ -1,7 +1,7 @@ { "Slug": "plugin-domains/credentials", "Title": "Credentials", - "Html": "\n\u003Cp\u003EAuthentication establishes whether an entity can be trusted to act as a particular identity. A credential provides the evidence used to establish that trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats credentials as a distinct domain rather than embedding authentication secrets directly into user records or authentication flows.\u003C/p\u003E\n\u003Cp\u003EThe \u003Cstrong\u003ECredentials Plugin Domain\u003C/strong\u003E manages the lifecycle, verification, and security of authentication credentials while allowing the authentication engine to remain independent of a particular credential type.\u003C/p\u003E\n\u003Cp\u003EThe current reference implementation supports password credentials. Its architecture establishes the separation of responsibilities needed to accommodate additional credential mechanisms without redefining the underlying authentication model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003EUnderstanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential represents authentication material or a mechanism through which an identity can demonstrate possession of an accepted authentication factor.\u003C/p\u003E\n\u003Cp\u003ECredentials may take different forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECredential mechanism\u003C/th\u003E\n\u003Cth\u003EAuthentication evidence\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPassword\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EKnowledge of a secret verified against a protected representation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EOne-time code\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EPossession of a time-limited or single-use authentication value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECryptographic credential\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EProof of control over a private key or authenticator\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EExternal identity provider\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA trusted authentication assertion established through an external protocol\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese mechanisms do not necessarily share the same storage, verification, or lifecycle requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, a password requires secure secret verification, while a cryptographic credential may require signature validation and public-key metadata. An external provider may require validating protocol assertions rather than storing a reusable secret.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s credential architecture is intended to accommodate these differences through explicit credential-specific behavior.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation:\u003C/strong\u003E Password credentials are supported. The other mechanisms illustrate the broader credential model and its intended extension boundaries; they are not presented as implemented credential types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-user-identity\u0022\u003ECredentials and User Identity\u003C/h2\u003E\n\u003Cp\u003EA user identity and a credential are separate concepts.\u003C/p\u003E\n\u003Cp\u003EThe Users domain manages identity ownership, identifiers, and account lifecycle. The Credentials domain manages the authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThis separation means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA user can exist independently of a particular credential.\u003C/li\u003E\n\u003Cli\u003ECredential state can change without redefining the user\u0027s identity.\u003C/li\u003E\n\u003Cli\u003EAn identity can conceptually be associated with multiple authentication mechanisms.\u003C/li\u003E\n\u003Cli\u003ECredential verification does not replace user lifecycle or account eligibility checks.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA username or email address is generally an \u003Cstrong\u003Eidentifier\u003C/strong\u003E, not a credential.\u003C/p\u003E\n\u003Cp\u003EAn identifier helps resolve which identity is involved in an authentication attempt. A credential provides evidence that the actor is entitled to authenticate as that identity.\u003C/p\u003E\n\u003Cp\u003EFor a password-based login, these responsibilities work together:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe submitted identifier is resolved to an identity.\u003C/li\u003E\n\u003Cli\u003EThe applicable password credential is located.\u003C/li\u003E\n\u003Cli\u003EThe submitted secret is verified.\u003C/li\u003E\n\u003Cli\u003EAuthentication policies and relevant security conditions are evaluated.\u003C/li\u003E\n\u003Cli\u003EThe authentication engine establishes authentication state if the flow succeeds.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe credential domain participates in establishing identity proof, but it does not independently determine the entire authentication outcome.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022responsibilities-across-the-framework\u0022\u003EResponsibilities Across the Framework\u003C/h2\u003E\n\u003Cp\u003ECredential support spans several architectural layers, each with a different responsibility.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELayer\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECore\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EFoundational authentication contracts and security concepts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EServer\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication flow execution, orchestration, and authentication-state management\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Bridge\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContracts connecting credential capabilities to the authentication server\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Contracts\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EShared credential operation models and results\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Reference Implementation\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management, validation, recovery, and lifecycle behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPersistence\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EStorage of credential records and related security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe distinction is between \u003Cstrong\u003Eusing credentials to authenticate\u003C/strong\u003E and \u003Cstrong\u003Emanaging credentials as domain resources\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication engine coordinates authentication flows. The Credentials domain owns the rules governing credential records, their usability, and their lifecycle.\u003C/p\u003E\n\u003Cp\u003EAn application may integrate a custom credential system through the relevant authentication contracts without necessarily adopting the reference credential management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-credentials\u0022\u003EPassword Credentials\u003C/h2\u003E\n\u003Cp\u003EPassword is the first supported credential mechanism in UltimateAuth.\u003C/p\u003E\n\u003Cp\u003EA password credential represents a password-based authentication factor associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation separates the submitted secret from its protected representation and the credential\u0027s operational state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022secret-protection\u0022\u003ESecret Protection\u003C/h3\u003E\n\u003Cp\u003EPasswords must not be stored as plaintext or returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EThe password verification process operates against a protected password representation using the configured password hashing infrastructure.\u003C/p\u003E\n\u003Cp\u003ECredential management APIs should expose only the information required for their intended operations, not the underlying secret.\u003C/p\u003E\n\u003Cp\u003EThis separation also allows password verification and password management to evolve without changing the user identity model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-security-state\u0022\u003ECredential Security State\u003C/h3\u003E\n\u003Cp\u003EA credential is not necessarily usable merely because a matching secret has been supplied.\u003C/p\u003E\n\u003Cp\u003ECredential validation must also account for relevant lifecycle and security conditions, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the credential is active or revoked.\u003C/li\u003E\n\u003Cli\u003EWhether an applicable expiration condition has been reached.\u003C/li\u003E\n\u003Cli\u003EWhether the credential belongs to the intended identity.\u003C/li\u003E\n\u003Cli\u003EWhether the requested operation is permitted.\u003C/li\u003E\n\u003Cli\u003EWhether the credential is eligible for the authentication mechanism being executed.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESecret verification and credential usability are related but distinct decisions.\u003C/p\u003E\n\u003Cp\u003EA correctly supplied password must not make a revoked or otherwise unusable credential valid.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-validation\u0022\u003ECredential Validation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates credential-specific verification from the broader authentication flow.\u003C/p\u003E\n\u003Cp\u003EFor password authentication, credential validation includes evaluating the supplied secret against the stored protected representation and enforcing the relevant credential conditions.\u003C/p\u003E\n\u003Cp\u003EThe authentication server then uses the result within its authentication flow.\u003C/p\u003E\n\u003Cp\u003EThis boundary matters because credential verification is not equivalent to session creation or token issuance.\u003C/p\u003E\n\u003Cp\u003EA successful credential check establishes evidence for authentication. The authentication engine remains responsible for determining whether the flow can complete and what authentication state should be created.\u003C/p\u003E\n\u003Cp\u003EThe same architectural distinction applies to other credential mechanisms.\u003C/p\u003E\n\u003Cp\u003EA future cryptographic credential validator, for example, would verify a different kind of proof, but it would still participate in a broader authentication decision rather than independently issuing authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-lifecycle\u0022\u003ECredential Lifecycle\u003C/h2\u003E\n\u003Cp\u003ECredentials are managed resources with their own lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe reference domain provides operations around credential creation, secret changes, revocation, and recovery.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EAdd\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERegister a credential for an identity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChange secret\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace authentication material through an authorized operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMake a credential unusable\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EBegin reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EInitiate a controlled credential recovery process\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EComplete reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace a secret after satisfying recovery requirements\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese operations have different security requirements.\u003C/p\u003E\n\u003Cp\u003EChanging a password while authenticated is not equivalent to recovering access without the existing password. Likewise, revoking a credential is not the same as invalidating every authentication session previously established using that credential.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats these distinctions explicitly rather than assuming that every credential mutation has the same security effect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-ownership-and-operation-scope\u0022\u003ECredential Ownership and Operation Scope\u003C/h2\u003E\n\u003Cp\u003ECredential management operations must be evaluated in the context of the actor, the target identity, and the requested operation.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between self-service, administrative, and anonymous recovery scenarios.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-operations\u0022\u003ESelf-Service Operations\u003C/h3\u003E\n\u003Cp\u003EAn authenticated user may manage credentials associated with their own identity, subject to the applicable permissions and validation rules.\u003C/p\u003E\n\u003Cp\u003EChanging a secret may require proving knowledge of the existing secret or satisfying another supported security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-operations\u0022\u003EAdministrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative credential management is performed against an explicitly identified target.\u003C/p\u003E\n\u003Cp\u003EAdministrative authority does not eliminate the requirement to preserve target identity consistency.\u003C/p\u003E\n\u003Cp\u003EFor example, a credential recovery operation must not modify a different user\u0027s credential merely because a supplied identifier resolves to another account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022anonymous-recovery\u0022\u003EAnonymous Recovery\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery allows an identity to regain access without an existing authenticated session.\u003C/p\u003E\n\u003Cp\u003EBecause the actor is not authenticated, the recovery process must establish sufficient proof before allowing credential modification.\u003C/p\u003E\n\u003Cp\u003EThese operation scopes are security boundaries, not merely different API routes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is one of the most security-sensitive responsibilities of the Credentials domain.\u003C/p\u003E\n\u003Cp\u003EThe password reset process is designed as a controlled, multi-stage operation rather than an unrestricted secret update.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginning-recovery\u0022\u003EBeginning Recovery\u003C/h3\u003E\n\u003Cp\u003EA recovery request identifies the intended account and initiates a proof-based process.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported recovery configuration, proof may take the form of a token or code delivered through an appropriate notification mechanism.\u003C/p\u003E\n\u003Cp\u003EThe proof must be bound to the intended recovery context and subject to server-controlled validity rules.\u003C/p\u003E\n\u003Cp\u003ERecovery material must not be exposed to an anonymous caller simply because that caller initiated the request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completing-recovery\u0022\u003ECompleting Recovery\u003C/h3\u003E\n\u003Cp\u003ECompleting recovery requires validating the supplied proof and its associated security conditions before applying a new secret.\u003C/p\u003E\n\u003Cp\u003EThe reference recovery model accounts for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProof validity and expiration.\u003C/li\u003E\n\u003Cli\u003ESingle-use recovery material.\u003C/li\u003E\n\u003Cli\u003EConsistency between the recovery target and intended identity.\u003C/li\u003E\n\u003Cli\u003ERejection of invalid or previously consumed proof.\u003C/li\u003E\n\u003Cli\u003EProtection against unauthorized credential replacement.\u003C/li\u003E\n\u003Cli\u003ESecurity-state updates following successful recovery.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA completed recovery must not leave the same proof available for another successful reset.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enumeration-resistance\u0022\u003EEnumeration Resistance\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery endpoints must avoid unnecessarily revealing whether an identifier belongs to an existing account.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates externally observable recovery responses from the internal decisions required to locate and manage the account.\u003C/p\u003E\n\u003Cp\u003EThis reduces account enumeration opportunities while preserving the ability to enforce recovery rules.\u003C/p\u003E\n\u003Cp\u003ERecovery behavior should also be combined with appropriate abuse protection and operational monitoring.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-authentication-sessions\u0022\u003ECredentials and Authentication Sessions\u003C/h2\u003E\n\u003Cp\u003EChanging or recovering a credential may require action against authentication sessions that were established before the change.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ecredential lifecycle and session lifecycle are separate responsibilities\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports security policies that determine how credential changes and resets affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EThe available policy model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPolicy\u003C/th\u003E\n\u003Cth\u003EIntended behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current session while revoking other applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe appropriate policy depends on the operation and the application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EA password reset performed through anonymous recovery may warrant different treatment from an authenticated password change.\u003C/p\u003E\n\u003Cp\u003EEnforcement also depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, revocation can be reflected through server-side session validation. In JWT-based modes, previously issued access tokens may remain usable until expiration unless additional validation mechanisms are involved.\u003C/p\u003E\n\u003Cp\u003ECredential changes therefore initiate the applicable security response; they do not imply identical immediate token invalidation across every mode.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E for the underlying differences.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022integration-with-the-users-domain\u0022\u003EIntegration with the Users Domain\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain does not need to own user records to manage authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EUser creation, deletion, or other lifecycle changes may require corresponding credential operations.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports coordination through integration contracts rather than requiring the Users and Credentials implementations to depend directly on one another.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may coordinate initial credential enrollment with user creation or perform credential cleanup as part of account removal.\u003C/p\u003E\n\u003Cp\u003EThese behaviors belong to explicit integration workflows.\u003C/p\u003E\n\u003Cp\u003ECreating a user does not inherently require creating a password credential, and deleting a user should not rely on an undocumented side effect to maintain credential consistency.\u003C/p\u003E\n\u003Cp\u003EThis distinction keeps identity lifecycle and credential lifecycle independently understandable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extending-credential-capabilities\u0022\u003EExtending Credential Capabilities\u003C/h2\u003E\n\u003Cp\u003ECredential mechanisms differ substantially in how they establish trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s architecture is intended to support additional mechanisms by separating credential-specific verification and management from authentication flow orchestration.\u003C/p\u003E\n\u003Cp\u003ESuch extensions may require their own:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECredential representation and metadata.\u003C/li\u003E\n\u003Cli\u003EEnrollment and verification procedures.\u003C/li\u003E\n\u003Cli\u003ESecurity and expiration rules.\u003C/li\u003E\n\u003Cli\u003ERecovery or revocation behavior.\u003C/li\u003E\n\u003Cli\u003EPersistence and integration contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ENot every credential mechanism needs to implement password-style secret changes or recovery.\u003C/p\u003E\n\u003Cp\u003EThe goal is to preserve consistent authentication boundaries while allowing each credential mechanism to enforce its own security requirements.\u003C/p\u003E\n\u003Cp\u003EFor most applications using password authentication, the reference implementation provides the recommended starting point.\u003C/p\u003E\n\u003Cp\u003EApplications with specialized requirements can customize supported services and integrations or provide alternative implementations through the relevant contracts.\u003C/p\u003E\n\u003Cp\u003EDetailed extension and replacement strategies belong in the dedicated customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022architectural-perspective\u0022\u003EArchitectural Perspective\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain establishes an important distinction within UltimateAuth:\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EA user identifies the account. A credential provides authentication evidence. The authentication engine decides how that evidence establishes authentication state.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EKeeping these responsibilities separate allows the framework to support identity management without tying authentication to one user entity, one secret format, or one credential mechanism.\u003C/p\u003E\n\u003Cp\u003EThe current password implementation provides the operational foundation. The broader architecture allows credential capabilities to evolve without turning the authentication engine into a monolithic credential management system.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand identity ownership, identifiers, and account lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how authenticated identities are evaluated against access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Review the architecture and responsibilities of Plugin Domains.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the authentication flow that consumes credential verification results, see \u003Ca href=\u0022/docs/fundamentals/flow-based-authentication\u0022\u003EFlow-Based Authentication\u003C/a\u003E.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EAuthentication establishes whether an entity can be trusted to act as a particular identity. A credential provides the evidence used to establish that trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats credentials as a distinct domain rather than embedding authentication secrets directly into user records or authentication flows.\u003C/p\u003E\n\u003Cp\u003EThe \u003Cstrong\u003ECredentials Plugin Domain\u003C/strong\u003E manages the lifecycle, verification, and security of authentication credentials while allowing the authentication engine to remain independent of a particular credential type.\u003C/p\u003E\n\u003Cp\u003EThe current reference implementation supports password credentials. Its architecture establishes the separation of responsibilities needed to accommodate additional credential mechanisms without redefining the underlying authentication model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-credentials\u0022\u003EUnderstanding Credentials\u003C/h2\u003E\n\u003Cp\u003EA credential represents authentication material or a mechanism through which an identity can demonstrate possession of an accepted authentication factor.\u003C/p\u003E\n\u003Cp\u003ECredentials may take different forms:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECredential mechanism\u003C/th\u003E\n\u003Cth\u003EAuthentication evidence\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPassword\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EKnowledge of a secret verified against a protected representation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EOne-time code\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EPossession of a time-limited or single-use authentication value\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECryptographic credential\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EProof of control over a private key or authenticator\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EExternal identity provider\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA trusted authentication assertion established through an external protocol\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese mechanisms do not necessarily share the same storage, verification, or lifecycle requirements.\u003C/p\u003E\n\u003Cp\u003EFor example, a password requires secure secret verification, while a cryptographic credential may require signature validation and public-key metadata. An external provider may require validating protocol assertions rather than storing a reusable secret.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s credential architecture is intended to accommodate these differences through explicit credential-specific behavior.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation:\u003C/strong\u003E Password credentials are supported. The other mechanisms illustrate the broader credential model and its intended extension boundaries; they are not presented as implemented credential types.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-user-identity\u0022\u003ECredentials and User Identity\u003C/h2\u003E\n\u003Cp\u003EA user identity and a credential are separate concepts.\u003C/p\u003E\n\u003Cp\u003EThe Users domain manages identity ownership, identifiers, and account lifecycle. The Credentials domain manages the authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThis separation means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA user can exist independently of a particular credential.\u003C/li\u003E\n\u003Cli\u003ECredential state can change without redefining the user\u0027s identity.\u003C/li\u003E\n\u003Cli\u003EAn identity can conceptually be associated with multiple authentication mechanisms.\u003C/li\u003E\n\u003Cli\u003ECredential verification does not replace user lifecycle or account eligibility checks.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA username or email address is generally an \u003Cstrong\u003Eidentifier\u003C/strong\u003E, not a credential.\u003C/p\u003E\n\u003Cp\u003EAn identifier helps resolve which identity is involved in an authentication attempt. A credential provides evidence that the actor is entitled to authenticate as that identity.\u003C/p\u003E\n\u003Cp\u003EFor a password-based login, these responsibilities work together:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe submitted identifier is resolved to an identity.\u003C/li\u003E\n\u003Cli\u003EThe applicable password credential is located.\u003C/li\u003E\n\u003Cli\u003EThe submitted secret is verified.\u003C/li\u003E\n\u003Cli\u003EAuthentication policies and relevant security conditions are evaluated.\u003C/li\u003E\n\u003Cli\u003EThe authentication engine establishes authentication state if the flow succeeds.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe credential domain participates in establishing identity proof, but it does not independently determine the entire authentication outcome.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022responsibilities-across-the-framework\u0022\u003EResponsibilities Across the Framework\u003C/h2\u003E\n\u003Cp\u003ECredential support spans several architectural layers, each with a different responsibility.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELayer\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECore\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EFoundational authentication contracts and security concepts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EServer\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication flow execution, orchestration, and authentication-state management\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Bridge\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EContracts connecting credential capabilities to the authentication server\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Contracts\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EShared credential operation models and results\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ECredentials Reference Implementation\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management, validation, recovery, and lifecycle behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPersistence\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EStorage of credential records and related security state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe distinction is between \u003Cstrong\u003Eusing credentials to authenticate\u003C/strong\u003E and \u003Cstrong\u003Emanaging credentials as domain resources\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication engine coordinates authentication flows. The Credentials domain owns the rules governing credential records, their usability, and their lifecycle.\u003C/p\u003E\n\u003Cp\u003EAn application may integrate a custom credential system through the relevant authentication contracts without necessarily adopting the reference credential management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-credentials\u0022\u003EPassword Credentials\u003C/h2\u003E\n\u003Cp\u003EPassword is the first supported credential mechanism in UltimateAuth.\u003C/p\u003E\n\u003Cp\u003EA password credential represents a password-based authentication factor associated with an identity.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation separates the submitted secret from its protected representation and the credential\u0027s operational state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022secret-protection\u0022\u003ESecret Protection\u003C/h3\u003E\n\u003Cp\u003EPasswords must not be stored as plaintext or returned through credential management responses.\u003C/p\u003E\n\u003Cp\u003EThe password verification process operates against a protected password representation using the configured password hashing infrastructure.\u003C/p\u003E\n\u003Cp\u003ECredential management APIs should expose only the information required for their intended operations, not the underlying secret.\u003C/p\u003E\n\u003Cp\u003EThis separation also allows password verification and password management to evolve without changing the user identity model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-security-state\u0022\u003ECredential Security State\u003C/h3\u003E\n\u003Cp\u003EA credential is not necessarily usable merely because a matching secret has been supplied.\u003C/p\u003E\n\u003Cp\u003ECredential validation must also account for relevant lifecycle and security conditions, such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EWhether the credential is active or revoked.\u003C/li\u003E\n\u003Cli\u003EWhether an applicable expiration condition has been reached.\u003C/li\u003E\n\u003Cli\u003EWhether the credential belongs to the intended identity.\u003C/li\u003E\n\u003Cli\u003EWhether the requested operation is permitted.\u003C/li\u003E\n\u003Cli\u003EWhether the credential is eligible for the authentication mechanism being executed.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESecret verification and credential usability are related but distinct decisions.\u003C/p\u003E\n\u003Cp\u003EA correctly supplied password must not make a revoked or otherwise unusable credential valid.\u003C/p\u003E\n\u003Cp\u003EPassword strength, common-password detection, user similarity, and password reuse are governed by configurable security policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/password-policy\u0022\u003EPassword Policy \u0026amp; History\u003C/a\u003E for the built-in policy levels, defaults, and configuration options.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-validation\u0022\u003ECredential Validation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates credential-specific verification from the broader authentication flow.\u003C/p\u003E\n\u003Cp\u003EFor password authentication, credential validation includes evaluating the supplied secret against the stored protected representation and enforcing the relevant credential conditions.\u003C/p\u003E\n\u003Cp\u003EThe authentication server then uses the result within its authentication flow.\u003C/p\u003E\n\u003Cp\u003EThis boundary matters because credential verification is not equivalent to session creation or token issuance.\u003C/p\u003E\n\u003Cp\u003EA successful credential check establishes evidence for authentication. The authentication engine remains responsible for determining whether the flow can complete and what authentication state should be created.\u003C/p\u003E\n\u003Cp\u003EThe same architectural distinction applies to other credential mechanisms.\u003C/p\u003E\n\u003Cp\u003EA future cryptographic credential validator, for example, would verify a different kind of proof, but it would still participate in a broader authentication decision rather than independently issuing authentication state.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-lifecycle\u0022\u003ECredential Lifecycle\u003C/h2\u003E\n\u003Cp\u003ECredentials are managed resources with their own lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe reference domain provides operations around credential creation, secret changes, revocation, and recovery.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EAdd\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ERegister a credential for an identity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChange secret\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace authentication material through an authorized operation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMake a credential unusable\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EBegin reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EInitiate a controlled credential recovery process\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EComplete reset\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EReplace a secret after satisfying recovery requirements\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese operations have different security requirements.\u003C/p\u003E\n\u003Cp\u003EChanging a password while authenticated is not equivalent to recovering access without the existing password. Likewise, revoking a credential is not the same as invalidating every authentication session previously established using that credential.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth treats these distinctions explicitly rather than assuming that every credential mutation has the same security effect.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-ownership-and-operation-scope\u0022\u003ECredential Ownership and Operation Scope\u003C/h2\u003E\n\u003Cp\u003ECredential management operations must be evaluated in the context of the actor, the target identity, and the requested operation.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth distinguishes between self-service, administrative, and anonymous recovery scenarios.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-operations\u0022\u003ESelf-Service Operations\u003C/h3\u003E\n\u003Cp\u003EAn authenticated user may manage credentials associated with their own identity, subject to the applicable permissions and validation rules.\u003C/p\u003E\n\u003Cp\u003EChanging a secret may require proving knowledge of the existing secret or satisfying another supported security requirement.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-operations\u0022\u003EAdministrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative credential management is performed against an explicitly identified target.\u003C/p\u003E\n\u003Cp\u003EAdministrative authority does not eliminate the requirement to preserve target identity consistency.\u003C/p\u003E\n\u003Cp\u003EFor example, a credential recovery operation must not modify a different user\u0027s credential merely because a supplied identifier resolves to another account.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022anonymous-recovery\u0022\u003EAnonymous Recovery\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery allows an identity to regain access without an existing authenticated session.\u003C/p\u003E\n\u003Cp\u003EBecause the actor is not authenticated, the recovery process must establish sufficient proof before allowing credential modification.\u003C/p\u003E\n\u003Cp\u003EThese operation scopes are security boundaries, not merely different API routes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential Recovery\u003C/h2\u003E\n\u003Cp\u003ECredential recovery is one of the most security-sensitive responsibilities of the Credentials domain.\u003C/p\u003E\n\u003Cp\u003EThe password reset process is designed as a controlled, multi-stage operation rather than an unrestricted secret update.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022beginning-recovery\u0022\u003EBeginning Recovery\u003C/h3\u003E\n\u003Cp\u003EA recovery request identifies the intended account and initiates a proof-based process.\u003C/p\u003E\n\u003Cp\u003EDepending on the supported recovery configuration, proof may take the form of a token or code delivered through an appropriate notification mechanism.\u003C/p\u003E\n\u003Cp\u003EThe proof must be bound to the intended recovery context and subject to server-controlled validity rules.\u003C/p\u003E\n\u003Cp\u003ERecovery material must not be exposed to an anonymous caller simply because that caller initiated the request.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022completing-recovery\u0022\u003ECompleting Recovery\u003C/h3\u003E\n\u003Cp\u003ECompleting recovery requires validating the supplied proof and its associated security conditions before applying a new secret.\u003C/p\u003E\n\u003Cp\u003EThe reference recovery model accounts for:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProof validity and expiration.\u003C/li\u003E\n\u003Cli\u003ESingle-use recovery material.\u003C/li\u003E\n\u003Cli\u003EConsistency between the recovery target and intended identity.\u003C/li\u003E\n\u003Cli\u003ERejection of invalid or previously consumed proof.\u003C/li\u003E\n\u003Cli\u003EProtection against unauthorized credential replacement.\u003C/li\u003E\n\u003Cli\u003ESecurity-state updates following successful recovery.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA completed recovery must not leave the same proof available for another successful reset.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enumeration-resistance\u0022\u003EEnumeration Resistance\u003C/h3\u003E\n\u003Cp\u003EAnonymous recovery endpoints must avoid unnecessarily revealing whether an identifier belongs to an existing account.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates externally observable recovery responses from the internal decisions required to locate and manage the account.\u003C/p\u003E\n\u003Cp\u003EThis reduces account enumeration opportunities while preserving the ability to enforce recovery rules.\u003C/p\u003E\n\u003Cp\u003ERecovery behavior should also be combined with appropriate abuse protection and operational monitoring.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credentials-and-authentication-sessions\u0022\u003ECredentials and Authentication Sessions\u003C/h2\u003E\n\u003Cp\u003EChanging or recovering a credential may require action against authentication sessions that were established before the change.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Cstrong\u003Ecredential lifecycle and session lifecycle are separate responsibilities\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports security policies that determine how credential changes and resets affect existing sessions.\u003C/p\u003E\n\u003Cp\u003EThe available policy model includes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPolicy\u003C/th\u003E\n\u003Cth\u003EIntended behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve existing sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeepCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPreserve the current session while revoking other applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe appropriate policy depends on the operation and the application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EA password reset performed through anonymous recovery may warrant different treatment from an authenticated password change.\u003C/p\u003E\n\u003Cp\u003EEnforcement also depends on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EIn session-authoritative modes, revocation can be reflected through server-side session validation. In JWT-based modes, previously issued access tokens may remain usable until expiration unless additional validation mechanisms are involved.\u003C/p\u003E\n\u003Cp\u003ECredential changes therefore initiate the applicable security response; they do not imply identical immediate token invalidation across every mode.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E for the underlying differences.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022integration-with-the-users-domain\u0022\u003EIntegration with the Users Domain\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain does not need to own user records to manage authentication material associated with an identity.\u003C/p\u003E\n\u003Cp\u003EUser creation, deletion, or other lifecycle changes may require corresponding credential operations.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports coordination through integration contracts rather than requiring the Users and Credentials implementations to depend directly on one another.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may coordinate initial credential enrollment with user creation or perform credential cleanup as part of account removal.\u003C/p\u003E\n\u003Cp\u003EThese behaviors belong to explicit integration workflows.\u003C/p\u003E\n\u003Cp\u003ECreating a user does not inherently require creating a password credential, and deleting a user should not rely on an undocumented side effect to maintain credential consistency.\u003C/p\u003E\n\u003Cp\u003EThis distinction keeps identity lifecycle and credential lifecycle independently understandable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022extending-credential-capabilities\u0022\u003EExtending Credential Capabilities\u003C/h2\u003E\n\u003Cp\u003ECredential mechanisms differ substantially in how they establish trust.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s architecture is intended to support additional mechanisms by separating credential-specific verification and management from authentication flow orchestration.\u003C/p\u003E\n\u003Cp\u003ESuch extensions may require their own:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECredential representation and metadata.\u003C/li\u003E\n\u003Cli\u003EEnrollment and verification procedures.\u003C/li\u003E\n\u003Cli\u003ESecurity and expiration rules.\u003C/li\u003E\n\u003Cli\u003ERecovery or revocation behavior.\u003C/li\u003E\n\u003Cli\u003EPersistence and integration contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ENot every credential mechanism needs to implement password-style secret changes or recovery.\u003C/p\u003E\n\u003Cp\u003EThe goal is to preserve consistent authentication boundaries while allowing each credential mechanism to enforce its own security requirements.\u003C/p\u003E\n\u003Cp\u003EFor most applications using password authentication, the reference implementation provides the recommended starting point.\u003C/p\u003E\n\u003Cp\u003EApplications with specialized requirements can customize supported services and integrations or provide alternative implementations through the relevant contracts.\u003C/p\u003E\n\u003Cp\u003EDetailed extension and replacement strategies belong in the dedicated customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022architectural-perspective\u0022\u003EArchitectural Perspective\u003C/h2\u003E\n\u003Cp\u003EThe Credentials domain establishes an important distinction within UltimateAuth:\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EA user identifies the account. A credential provides authentication evidence. The authentication engine decides how that evidence establishes authentication state.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EKeeping these responsibilities separate allows the framework to support identity management without tying authentication to one user entity, one secret format, or one credential mechanism.\u003C/p\u003E\n\u003Cp\u003EThe current password implementation provides the operational foundation. The broader architecture allows credential capabilities to evolve without turning the authentication engine into a monolithic credential management system.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers\u003C/a\u003E \u2014 Understand identity ownership, identifiers, and account lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how authenticated identities are evaluated against access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/overview\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Review the architecture and responsibilities of Plugin Domains.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the authentication flow that consumes credential verification results, see \u003Ca href=\u0022/docs/fundamentals/flow-based-auth\u0022\u003EFlow-Based Authentication\u003C/a\u003E.\u003C/p\u003E\n", "Headings": [ { "Id": "understanding-credentials", @@ -109,6 +109,6 @@ "Level": 0 } ], - "SourceHash": "F0E1E773A693F7405C202D30CDB37AF1154B0EA97BCE385786157DF1B6E20D24", + "SourceHash": "E53D4D20E053D0FF3D3EB37C8DF7A8DD3FACA8F2D9959442B958318742B5AED1", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/policies.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/policies.json index 0251d021..02ccc7b1 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/policies.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/policies.json @@ -159,6 +159,6 @@ "Level": 0 } ], - "SourceHash": "05FA98F9ED56849BAB9A4450CA6B85F273091D8A4F1422F7238421375E5DCA62", + "SourceHash": "DFAC894A519ADC862CC8E8B5450A17DC934DAAAB2004C631BFD9594CC7AA37E4", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/profile-validation-limits.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/profile-validation-limits.json new file mode 100644 index 00000000..3750624d --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/profile-validation-limits.json @@ -0,0 +1,44 @@ +{ + "Slug": "plugin-domains/profile-validation-limits", + "Title": "Profile Validation \u0026 Limits", + "Html": "\n\u003Cp\u003EUltimateAuth enforces framework-level safety limits on standard user profile fields and metadata.\u003C/p\u003E\n\u003Cp\u003EThese limits protect applications from excessively large profile values, unnecessary storage consumption, and resource exhaustion.\u003C/p\u003E\n\u003Cp\u003EThey apply consistently to the reference Users domain, regardless of whether InMemory or Entity Framework Core persistence is used.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022profile-field-limits\u0022\u003EProfile Field Limits\u003C/h2\u003E\n\u003Cp\u003EThe following maximum lengths are enforced:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EField\u003C/th\u003E\n\u003Cth style=\u0022text-align: right;\u0022\u003EMaximum characters\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFirstName\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELastName\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDisplayName\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E256\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EGender\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBio\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E4,096\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELanguage\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E64\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ETimeZone\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECulture\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E64\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAll fields are optional unless an application introduces additional requirements.\u003C/p\u003E\n\u003Cp\u003ELimits are defined centrally in \u003Ccode\u003EUserProfileLimits\u003C/code\u003E and cannot be increased through application configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022metadata-limits\u0022\u003EMetadata Limits\u003C/h2\u003E\n\u003Cp\u003EUser profiles support optional key-value metadata.\u003C/p\u003E\n\u003Cp\u003ETo prevent unbounded metadata storage, UltimateAuth enforces the following limits:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConstraint\u003C/th\u003E\n\u003Cth style=\u0022text-align: right;\u0022\u003EMaximum\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMetadata entries\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E32\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EKey length\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E128 characters\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EValue length\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E2,048 characters\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ETotal metadata size\u003C/td\u003E\n\u003Ctd style=\u0022text-align: right;\u0022\u003E8,192 UTF-8 bytes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe total size includes metadata keys and values.\u003C/p\u003E\n\u003Cp\u003EThese limits apply to the complete metadata collection, not individually to each entry.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-enforcement\u0022\u003EValidation Enforcement\u003C/h2\u003E\n\u003Cp\u003EProfile safety limits are enforced during:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EUser creation.\u003C/li\u003E\n\u003Cli\u003EAdditional profile creation.\u003C/li\u003E\n\u003Cli\u003EProfile updates.\u003C/li\u003E\n\u003Cli\u003EProfile cloning.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EValidation occurs before invalid data is persisted.\u003C/p\u003E\n\u003Cp\u003EFramework-level safety validation is independent of \u003Ccode\u003EIUserProfileValidator\u003C/code\u003E, ensuring that replacing the application validator cannot bypass these limits.\u003C/p\u003E\n\u003Cp\u003EThe same restrictions apply to both self-service and administrative operations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-validation\u0022\u003ECustom Validation\u003C/h2\u003E\n\u003Cp\u003EApplications can implement \u003Ccode\u003EIUserProfileValidator\u003C/code\u003E to introduce additional validation rules.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may require a display name, restrict accepted languages, or enforce a shorter biography.\u003C/p\u003E\n\u003Cp\u003ECustom validation can be stricter than the framework limits but cannot override the built-in safety boundaries.\u003C/p\u003E\n\u003Cp\u003EThe recommended approach is to use \u003Ccode\u003EIUserProfileValidator\u003C/code\u003E for application-specific rules while relying on UltimateAuth for baseline safety validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-errors\u0022\u003EValidation Errors\u003C/h2\u003E\n\u003Cp\u003EWhen a profile exceeds the supported limits, UltimateAuth rejects the operation with a validation error.\u003C/p\u003E\n\u003Cp\u003ECommon error codes include:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EError code\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprofile_field_too_long\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EA standard profile field exceeds its maximum length.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprofile_metadata_entries_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe metadata collection contains too many entries.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprofile_metadata_key_too_long\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EA metadata key exceeds its maximum length.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprofile_metadata_value_too_long\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EA metadata value exceeds its maximum length.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprofile_metadata_too_large\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe combined metadata size exceeds the allowed limit.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EApplications should use the error \u003Ccode\u003ECode\u003C/code\u003E rather than relying on exception message text.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistence-considerations\u0022\u003EPersistence Considerations\u003C/h2\u003E\n\u003Cp\u003EThe reference Entity Framework Core implementation configures profile column lengths using the same framework limits.\u003C/p\u003E\n\u003Cp\u003EExisting databases may require a migration when upgrading to a version that introduces these constraints.\u003C/p\u003E\n\u003Cp\u003EBefore applying restrictive database changes, review existing profile records for values exceeding the new limits.\u003C/p\u003E\n\u003Cp\u003EThe InMemory implementation uses the same application-level validation rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h2\u003E\n\u003Cp\u003EProfile limits are a defense-in-depth measure.\u003C/p\u003E\n\u003Cp\u003EThey do not replace HTTP request size limits, rate limiting, or application-specific validation.\u003C/p\u003E\n\u003Cp\u003EApplications should configure these additional protections according to their deployment requirements.\u003C/p\u003E\n\u003Cp\u003EFor an overview of user identity and profile management, see \u003Ca href=\u0022/docs/plugin-domains/users\u0022\u003EUsers Domain\u003C/a\u003E.\u003C/p\u003E\n", + "Headings": [ + { + "Id": "profile-field-limits", + "Text": "Profile Field Limits", + "Level": 0 + }, + { + "Id": "metadata-limits", + "Text": "Metadata Limits", + "Level": 0 + }, + { + "Id": "validation-enforcement", + "Text": "Validation Enforcement", + "Level": 0 + }, + { + "Id": "custom-validation", + "Text": "Custom Validation", + "Level": 0 + }, + { + "Id": "validation-errors", + "Text": "Validation Errors", + "Level": 0 + }, + { + "Id": "persistence-considerations", + "Text": "Persistence Considerations", + "Level": 0 + }, + { + "Id": "security-considerations", + "Text": "Security Considerations", + "Level": 0 + } + ], + "SourceHash": "5F102B8246218017DC2F9A89EB40C588773A804D661CE3C97625DA31C0461590", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/users.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/users.json index 1454edff..0c270314 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/users.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/plugin-domains/users.json @@ -1,7 +1,7 @@ { "Slug": "plugin-domains/users", "Title": "Users", - "Html": "\n\u003Cp\u003EA user is more than a database record, an email address, or a profile.\u003C/p\u003E\n\u003Cp\u003EIn UltimateAuth, user identity is modeled through a separation of \u003Cstrong\u003Eauthentication identity, account lifecycle, identifiers, and application-specific user information\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThis distinction allows the authentication engine to operate independently of a particular user entity or persistence model, while the Users Plugin Domain provides the capabilities needed to manage identities throughout their lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe Users domain builds on the authentication infrastructure provided by UltimateAuth Core and Server. It does not redefine how authentication works; it defines how user identities are represented, maintained, and integrated with that infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-identity\u0022\u003EUnderstanding User Identity\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between three aspects of a user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EComponent\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELifecycle\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount existence, status, and security-relevant state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EIdentifiers\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EValues through which a user can be identified or discovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EProfile\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EApplication-specific information associated with the user\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese components represent different responsibilities and should not be treated as interchangeable.\u003C/p\u003E\n\u003Cp\u003EFor example, changing a display name should not affect authentication state. Verifying an email address should not implicitly change account status. Suspending an account should not require deleting its identifiers or profile.\u003C/p\u003E\n\u003Cp\u003ESeparating these concerns makes identity operations more predictable and allows security-sensitive changes to be handled explicitly.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-identity-in-core-and-server\u0022\u003EUser Identity in Core and Server\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth does not require applications to adopt the Users Plugin Domain simply to authenticate users.\u003C/p\u003E\n\u003Cp\u003ECore and Server provide the foundational authentication infrastructure, including the contracts and runtime mechanisms needed to associate authentication operations with user identities.\u003C/p\u003E\n\u003Cp\u003EAt this level, a user is primarily an \u003Cstrong\u003Eauthentication subject\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication system needs to establish which identity is involved in an operation, evaluate the relevant security conditions, and maintain the appropriate authentication state.\u003C/p\u003E\n\u003Cp\u003EIt does not inherently need to know the user\u0027s display name, profile fields, or how the application\u0027s user records are stored.\u003C/p\u003E\n\u003Cp\u003EThis separation is intentional:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ECore\u003C/strong\u003E defines foundational authentication concepts, contracts, and security semantics.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer\u003C/strong\u003E executes authentication flows and enforces the relevant runtime security boundaries.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUsers\u003C/strong\u003E provides higher-level identity management capabilities through a dedicated Plugin Domain.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn application with an existing identity system can integrate that system with UltimateAuth\u0027s authentication infrastructure without necessarily adopting the reference user model.\u003C/p\u003E\n\u003Cp\u003EThe authentication identity is therefore a framework-level concept, while the complete user management model belongs to the application or the Users domain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-users-domain-adds\u0022\u003EWhat the Users Domain Adds\u003C/h2\u003E\n\u003Cp\u003EThe Users Plugin Domain provides a structured approach to managing user identities beyond the minimum requirements of authentication.\u003C/p\u003E\n\u003Cp\u003EIts responsibilities include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECreating and managing user accounts.\u003C/li\u003E\n\u003Cli\u003EMaintaining account lifecycle and status.\u003C/li\u003E\n\u003Cli\u003ERegistering and managing user identifiers.\u003C/li\u003E\n\u003Cli\u003EEnforcing identifier ownership and verification rules.\u003C/li\u003E\n\u003Cli\u003EApplying user management validation and access boundaries.\u003C/li\u003E\n\u003Cli\u003ECoordinating security-relevant changes with authentication infrastructure.\u003C/li\u003E\n\u003Cli\u003EExposing user management operations through application services and shared contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese capabilities are distinct from credential management.\u003C/p\u003E\n\u003Cp\u003EThe Users domain determines \u003Cstrong\u003Ewhich identity an identifier belongs to\u003C/strong\u003E and maintains the relevant user state. The Credentials domain manages the authentication credentials associated with identities.\u003C/p\u003E\n\u003Cp\u003EFor example, an email address is a user identifier; a password is a credential. They may participate in the same login flow, but they have different ownership and lifecycle rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-lifecycle\u0022\u003EAccount Lifecycle\u003C/h2\u003E\n\u003Cp\u003EThe lifecycle represents the existence and security-relevant state of a user account.\u003C/p\u003E\n\u003Cp\u003EIn the reference model, lifecycle information includes account creation, account status, and security state used to coordinate authentication-related changes.\u003C/p\u003E\n\u003Cp\u003EAccount status may represent conditions such as active, suspended, or deleted, according to the supported lifecycle model.\u003C/p\u003E\n\u003Cp\u003EThese states are not merely descriptive profile properties. They can influence whether an account is eligible to participate in authentication and other protected operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-state-and-session-invalidation\u0022\u003ESecurity State and Session Invalidation\u003C/h3\u003E\n\u003Cp\u003EUser lifecycle changes may require existing authentication state to be invalidated.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates the decision to change a user\u0027s security state from the mechanisms used to enforce that change.\u003C/p\u003E\n\u003Cp\u003EDepending on the configured authentication mode, enforcement may involve server-side session invalidation, token lifecycle rules, or other supported security mechanisms.\u003C/p\u003E\n\u003Cp\u003EThis distinction matters because not every authentication mode can invalidate an already-issued credential immediately.\u003C/p\u003E\n\u003Cp\u003EFor example, a locally validated JWT may remain valid until expiration unless the protected resource performs additional server-side validation.\u003C/p\u003E\n\u003Cp\u003EThe Users domain participates in security-sensitive account operations, while the authentication infrastructure determines how the resulting security state is enforced.\u003C/p\u003E\n\u003Cp\u003EFor more information, see \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-identifiers\u0022\u003EUser Identifiers\u003C/h2\u003E\n\u003Cp\u003EAn identifier is a value associated with a user that allows the identity to be referenced or resolved.\u003C/p\u003E\n\u003Cp\u003ECommon examples include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EEmail addresses.\u003C/li\u003E\n\u003Cli\u003EUsernames.\u003C/li\u003E\n\u003Cli\u003EPhone numbers.\u003C/li\u003E\n\u003Cli\u003EApplication-defined identifier types.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth treats identifiers as independent identity records rather than assuming that every user has exactly one email address or username.\u003C/p\u003E\n\u003Cp\u003EThis supports applications with different identity requirements without changing the underlying authentication model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-properties\u0022\u003EIdentifier Properties\u003C/h3\u003E\n\u003Cp\u003EThe reference identifier model distinguishes several important properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EType\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the kind of identifier, such as email or username\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EValue\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EThe identifier value associated with the user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ENormalized value\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA representation used for consistent lookup and comparison\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPrimary state\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndicates the identifier\u0027s designated primary role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EVerification state\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndicates whether ownership has been verified\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese properties address separate questions.\u003C/p\u003E\n\u003Cp\u003EAn identifier may exist without being verified. A verified identifier does not automatically become primary. A primary identifier does not necessarily satisfy every authentication policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-resolution\u0022\u003ELogin Identifier Resolution\u003C/h3\u003E\n\u003Cp\u003EIdentifier resolution determines how an incoming login identifier is associated with a user identity.\u003C/p\u003E\n\u003Cp\u003EIn the reference approach, primary identifiers define the intended login surface, subject to the configured identifier resolution and verification policies.\u003C/p\u003E\n\u003Cp\u003EApplications can control which identifier types are accepted and how identifiers are resolved.\u003C/p\u003E\n\u003Cp\u003EThis allows authentication to support different identity strategies without embedding email-specific or username-specific assumptions into Core.\u003C/p\u003E\n\u003Cp\u003EThe distinction is important: \u003Cstrong\u003Ean identifier is identity data; whether it is acceptable for login is an authentication policy decision.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-ownership-and-verification\u0022\u003EIdentifier Ownership and Verification\u003C/h3\u003E\n\u003Cp\u003EIdentifier verification is a security operation, not a simple update to a Boolean property.\u003C/p\u003E\n\u003Cp\u003EMarking an identifier as verified must be based on an appropriate ownership proof or an explicitly authorized administrative operation.\u003C/p\u003E\n\u003Cp\u003EThe reference domain therefore distinguishes between:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERegistering an identifier.\u003C/li\u003E\n\u003Cli\u003EEstablishing ownership of an identifier.\u003C/li\u003E\n\u003Cli\u003EVerifying that ownership.\u003C/li\u003E\n\u003Cli\u003EChanging an identifier\u0027s role or status.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn anonymous or self-service request must not be able to claim verified ownership merely by supplying a verification flag.\u003C/p\u003E\n\u003Cp\u003ELikewise, operations targeting another user\u0027s identifiers must respect the applicable authorization and ownership boundaries.\u003C/p\u003E\n\u003Cp\u003EThis prevents identifier management from becoming an alternative route around authentication security.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profiles\u0022\u003EUser Profiles\u003C/h2\u003E\n\u003Cp\u003EA profile contains information about the user that is not itself an authentication credential or an identity verification proof.\u003C/p\u003E\n\u003Cp\u003ETypical profile information may include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDisplay name and personal information.\u003C/li\u003E\n\u003Cli\u003ELocalization preferences.\u003C/li\u003E\n\u003Cli\u003EApplication metadata.\u003C/li\u003E\n\u003Cli\u003EOther domain-specific user attributes.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth deliberately separates profile data from the security-critical identity model.\u003C/p\u003E\n\u003Cp\u003EApplications should be able to evolve their profile structure without redesigning authentication flows or changing how identifiers are verified.\u003C/p\u003E\n\u003Cp\u003EA profile attribute may still be relevant to application authorization or business rules, but it should not automatically be treated as proof of identity.\u003C/p\u003E\n\u003Cp\u003EThe reference model provides a starting point; applications remain responsible for deciding which additional user attributes belong in their own domain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022batch-user-summary-retrieval\u0022\u003EBatch User Summary Retrieval\u003C/h2\u003E\n\u003Cp\u003EApplications frequently maintain relationships between users without storing complete user profile information alongside those relationships.\u003C/p\u003E\n\u003Cp\u003EFor example, a project management application may store a collection of user keys representing project members.\u003C/p\u003E\n\u003Cp\u003EWhen displaying those members, the application needs their current names, usernames, and other summary information.\u003C/p\u003E\n\u003Cp\u003ERetrieving each user independently can introduce unnecessary requests and repeated persistence operations.\u003C/p\u003E\n\u003Cp\u003EThe Users domain addresses this through batch user summary retrieval.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022iusersummaryquerystore\u0022\u003EIUserSummaryQueryStore\u003C/h3\u003E\n\u003Cp\u003EThe \u003Ccode\u003EIUserSummaryQueryStore\u003C/code\u003E contract supports retrieving user summaries by their keys:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ETask\u0026lt;IReadOnlyList\u0026lt;UserSummary\u0026gt;\u0026gt; GetByKeysAsync(IReadOnlyCollection\u0026lt;UserKey\u0026gt; userKeys, CancellationToken ct = default);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe operation retrieves summaries for a known collection of users within the store\u0027s tenant context.\u003C/p\u003E\n\u003Cp\u003EThe reference implementations include support for Entity Framework Core and InMemory persistence.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-service-and-authorization\u0022\u003EApplication Service and Authorization\u003C/h3\u003E\n\u003Cp\u003EBatch retrieval is exposed through the Users application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EGetUsersByKeysAsync(AccessContext context, IReadOnlyCollection\u0026lt;UserKey\u0026gt; userKeys, CancellationToken ct = default)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application service coordinates authorization and persistence access.\u003C/p\u003E\n\u003Cp\u003EThe operation is executed through the standard \u003Ccode\u003EAccessOrchestrator\u003C/code\u003E, ensuring that batch retrieval participates in UltimateAuth\u0027s existing authorization pipeline.\u003C/p\u003E\n\u003Cp\u003EThe public Client SDK exposes the functionality through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Users.GetByKeysAsync(userKeys)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022target-aware-access-control\u0022\u003ETarget-Aware Access Control\u003C/h3\u003E\n\u003Cp\u003EA batch request may target multiple users belonging to different groups, organizations, or application-defined security scopes.\u003C/p\u003E\n\u003Cp\u003EFor this reason, UltimateAuth supports multiple target identities in \u003Ccode\u003EAccessContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA policy can inspect the complete target collection before allowing the operation.\u003C/p\u003E\n\u003Cp\u003EThis makes it possible to implement restrictions such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAllowing access only to members of the actor\u0027s organization.\u003C/li\u003E\n\u003Cli\u003ERestricting visibility to users assigned to the same project.\u003C/li\u003E\n\u003Cli\u003ELimiting member directories to a particular group.\u003C/li\u003E\n\u003Cli\u003EDenying requests containing users outside an authorized scope.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe Users domain does not define these application-specific membership rules.\u003C/p\u003E\n\u003Cp\u003EInstead, it provides the batch operation and authorization context needed for applications to enforce them.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-appropriate-retrieval-method\u0022\u003EChoosing the Appropriate Retrieval Method\u003C/h3\u003E\n\u003Cp\u003EUse:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EGetUserAsync\u003C/code\u003E for detailed information about one user.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EQueryAsync\u003C/code\u003E for searching, filtering, sorting, and paginating users.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EGetByKeysAsync\u003C/code\u003E for retrieving summaries of a known collection of users.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EBatch retrieval supports up to 250 distinct user keys per request.\u003C/p\u003E\n\u003Cp\u003EIt is intended for targeted retrieval, not as a replacement for paginated user discovery.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022reference-implementation\u0022\u003EReference Implementation\u003C/h2\u003E\n\u003Cp\u003EThe Users Plugin Domain includes a reference implementation that demonstrates and provides a practical user management model.\u003C/p\u003E\n\u003Cp\u003EIt brings together lifecycle, identifiers, and profile management through application services and supporting abstractions.\u003C/p\u003E\n\u003Cp\u003ERather than exposing persistence operations as the primary application interface, the reference implementation places user management rules within the application service layer.\u003C/p\u003E\n\u003Cp\u003EThis layer coordinates operations such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EUser creation and account management.\u003C/li\u003E\n\u003Cli\u003EIdentifier registration and updates.\u003C/li\u003E\n\u003Cli\u003EIdentifier verification and ownership validation.\u003C/li\u003E\n\u003Cli\u003EAccount status changes.\u003C/li\u003E\n\u003Cli\u003EIntegration and lifecycle notifications.\u003C/li\u003E\n\u003Cli\u003ESecurity-related coordination with the authentication infrastructure.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis is an important architectural boundary.\u003C/p\u003E\n\u003Cp\u003EA request to verify an identifier, for example, is not equivalent to directly updating a database field. The application service is responsible for enforcing the rules that make the operation valid.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation provides a recommended starting point for these behaviors without requiring every application to use the same persistence technology or user profile structure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022domain-integration\u0022\u003EDomain Integration\u003C/h2\u003E\n\u003Cp\u003EUser management frequently affects other parts of an identity platform.\u003C/p\u003E\n\u003Cp\u003ECreating a user may be followed by credential enrollment. Changing account security state may require session invalidation. Deleting an account may require coordinated cleanup.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through explicit integration contracts rather than direct dependencies between Plugin Domain implementations.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EIUserLifecycleIntegration\u003C/code\u003E provides an integration boundary for user lifecycle operations.\u003C/p\u003E\n\u003Cp\u003EThis allows other capabilities to participate in user-related workflows while keeping the Users domain responsible for its own state and rules.\u003C/p\u003E\n\u003Cp\u003EThe distinction between domain ownership and cross-domain coordination is central to UltimateAuth\u0027s modular architecture.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022customizing-user-management\u0022\u003ECustomizing User Management\u003C/h2\u003E\n\u003Cp\u003EApplications are not required to accept every detail of the reference implementation.\u003C/p\u003E\n\u003Cp\u003EDepending on their requirements and the available extension contracts, they may:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProvide custom identifier resolution.\u003C/li\u003E\n\u003Cli\u003EConfigure supported identifier and verification policies.\u003C/li\u003E\n\u003Cli\u003EReplace storage implementations.\u003C/li\u003E\n\u003Cli\u003EIntegrate existing user repositories.\u003C/li\u003E\n\u003Cli\u003EExtend supported lifecycle and application service integrations.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor most applications, the recommended approach is to begin with the reference implementation and customize specific behavior rather than replacing the entire domain.\u003C/p\u003E\n\u003Cp\u003EMore advanced scenarios, including custom domain implementations and integration with existing identity systems, will be covered separately in the customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-user-model\u0022\u003EChoosing Your User Model\u003C/h2\u003E\n\u003Cp\u003EThere are two broad integration approaches.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EUse the reference Users domain\u003C/strong\u003E when the application needs a ready-made foundation for account lifecycle, identifiers, verification, and user management.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIntegrate an existing identity model\u003C/strong\u003E when the application already owns these responsibilities and needs UltimateAuth primarily for its authentication infrastructure.\u003C/p\u003E\n\u003Cp\u003ENeither approach changes the fundamental security requirements.\u003C/p\u003E\n\u003Cp\u003EIdentity resolution must be trustworthy, identifier ownership must be protected, and authentication state must be managed according to the configured security model.\u003C/p\u003E\n\u003Cp\u003EThe difference is which part of the application owns the user management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cp\u003EThe Users domain establishes identity ownership and account lifecycle, but it does not independently provide every capability of an identity platform.\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/credentials\u0022\u003ECredentials\u003C/a\u003E \u2014 Learn how authentication credentials are managed independently of user identifiers.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how identities are associated with permissions and access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Return to the overall Plugin Domain architecture.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the distinction between identity and authentication infrastructure, see \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EA user is more than a database record, an email address, or a profile.\u003C/p\u003E\n\u003Cp\u003EIn UltimateAuth, user identity is modeled through a separation of \u003Cstrong\u003Eauthentication identity, account lifecycle, identifiers, and application-specific user information\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThis distinction allows the authentication engine to operate independently of a particular user entity or persistence model, while the Users Plugin Domain provides the capabilities needed to manage identities throughout their lifecycle.\u003C/p\u003E\n\u003Cp\u003EThe Users domain builds on the authentication infrastructure provided by UltimateAuth Core and Server. It does not redefine how authentication works; it defines how user identities are represented, maintained, and integrated with that infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-user-identity\u0022\u003EUnderstanding User Identity\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between three aspects of a user:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EComponent\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELifecycle\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount existence, status, and security-relevant state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EIdentifiers\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EValues through which a user can be identified or discovered\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EProfile\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EApplication-specific information associated with the user\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese components represent different responsibilities and should not be treated as interchangeable.\u003C/p\u003E\n\u003Cp\u003EFor example, changing a display name should not affect authentication state. Verifying an email address should not implicitly change account status. Suspending an account should not require deleting its identifiers or profile.\u003C/p\u003E\n\u003Cp\u003ESeparating these concerns makes identity operations more predictable and allows security-sensitive changes to be handled explicitly.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-identity-in-core-and-server\u0022\u003EUser Identity in Core and Server\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth does not require applications to adopt the Users Plugin Domain simply to authenticate users.\u003C/p\u003E\n\u003Cp\u003ECore and Server provide the foundational authentication infrastructure, including the contracts and runtime mechanisms needed to associate authentication operations with user identities.\u003C/p\u003E\n\u003Cp\u003EAt this level, a user is primarily an \u003Cstrong\u003Eauthentication subject\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe authentication system needs to establish which identity is involved in an operation, evaluate the relevant security conditions, and maintain the appropriate authentication state.\u003C/p\u003E\n\u003Cp\u003EIt does not inherently need to know the user\u0027s display name, profile fields, or how the application\u0027s user records are stored.\u003C/p\u003E\n\u003Cp\u003EThis separation is intentional:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ECore\u003C/strong\u003E defines foundational authentication concepts, contracts, and security semantics.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer\u003C/strong\u003E executes authentication flows and enforces the relevant runtime security boundaries.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUsers\u003C/strong\u003E provides higher-level identity management capabilities through a dedicated Plugin Domain.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn application with an existing identity system can integrate that system with UltimateAuth\u0027s authentication infrastructure without necessarily adopting the reference user model.\u003C/p\u003E\n\u003Cp\u003EThe authentication identity is therefore a framework-level concept, while the complete user management model belongs to the application or the Users domain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-users-domain-adds\u0022\u003EWhat the Users Domain Adds\u003C/h2\u003E\n\u003Cp\u003EThe Users Plugin Domain provides a structured approach to managing user identities beyond the minimum requirements of authentication.\u003C/p\u003E\n\u003Cp\u003EIts responsibilities include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECreating and managing user accounts.\u003C/li\u003E\n\u003Cli\u003EMaintaining account lifecycle and status.\u003C/li\u003E\n\u003Cli\u003ERegistering and managing user identifiers.\u003C/li\u003E\n\u003Cli\u003EEnforcing identifier ownership and verification rules.\u003C/li\u003E\n\u003Cli\u003EApplying user management validation and access boundaries.\u003C/li\u003E\n\u003Cli\u003ECoordinating security-relevant changes with authentication infrastructure.\u003C/li\u003E\n\u003Cli\u003EExposing user management operations through application services and shared contracts.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese capabilities are distinct from credential management.\u003C/p\u003E\n\u003Cp\u003EThe Users domain determines \u003Cstrong\u003Ewhich identity an identifier belongs to\u003C/strong\u003E and maintains the relevant user state. The Credentials domain manages the authentication credentials associated with identities.\u003C/p\u003E\n\u003Cp\u003EFor example, an email address is a user identifier; a password is a credential. They may participate in the same login flow, but they have different ownership and lifecycle rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022account-lifecycle\u0022\u003EAccount Lifecycle\u003C/h2\u003E\n\u003Cp\u003EThe lifecycle represents the existence and security-relevant state of a user account.\u003C/p\u003E\n\u003Cp\u003EIn the reference model, lifecycle information includes account creation, account status, and security state used to coordinate authentication-related changes.\u003C/p\u003E\n\u003Cp\u003EAccount status may represent conditions such as active, suspended, or deleted, according to the supported lifecycle model.\u003C/p\u003E\n\u003Cp\u003EThese states are not merely descriptive profile properties. They can influence whether an account is eligible to participate in authentication and other protected operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-state-and-session-invalidation\u0022\u003ESecurity State and Session Invalidation\u003C/h3\u003E\n\u003Cp\u003EUser lifecycle changes may require existing authentication state to be invalidated.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth separates the decision to change a user\u0027s security state from the mechanisms used to enforce that change.\u003C/p\u003E\n\u003Cp\u003EDepending on the configured authentication mode, enforcement may involve server-side session invalidation, token lifecycle rules, or other supported security mechanisms.\u003C/p\u003E\n\u003Cp\u003EThis distinction matters because not every authentication mode can invalidate an already-issued credential immediately.\u003C/p\u003E\n\u003Cp\u003EFor example, a locally validated JWT may remain valid until expiration unless the protected resource performs additional server-side validation.\u003C/p\u003E\n\u003Cp\u003EThe Users domain participates in security-sensitive account operations, while the authentication infrastructure determines how the resulting security state is enforced.\u003C/p\u003E\n\u003Cp\u003EFor more information, see \u003Ca href=\u0022/docs/fundamentals/auth-modes\u0022\u003EAuthentication Modes\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-identifiers\u0022\u003EUser Identifiers\u003C/h2\u003E\n\u003Cp\u003EAn identifier is a value associated with a user that allows the identity to be referenced or resolved.\u003C/p\u003E\n\u003Cp\u003ECommon examples include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EEmail addresses.\u003C/li\u003E\n\u003Cli\u003EUsernames.\u003C/li\u003E\n\u003Cli\u003EPhone numbers.\u003C/li\u003E\n\u003Cli\u003EApplication-defined identifier types.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth treats identifiers as independent identity records rather than assuming that every user has exactly one email address or username.\u003C/p\u003E\n\u003Cp\u003EThis supports applications with different identity requirements without changing the underlying authentication model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-capacity-and-limits\u0022\u003EIdentifier Capacity and Limits\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth allows users to maintain multiple identifiers while providing configurable limits to prevent uncontrolled identifier growth.\u003C/p\u003E\n\u003Cp\u003EThe default configuration allows \u003Cstrong\u003E10 active identifiers per user\u003C/strong\u003E, shared across all identifier types.\u003C/p\u003E\n\u003Cp\u003EApplications can optionally configure additional per-type limits when their identity model requires them.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may allow several email addresses and phone numbers while restricting the number of usernames.\u003C/p\u003E\n\u003Cp\u003EIdentifier limits are independent of identifier uniqueness, ownership verification, and primary designation.\u003C/p\u003E\n\u003Cp\u003EA primary identifier consumes the same capacity as a non-primary identifier. Soft-deleted identifiers do not consume active capacity, although their normalized values may remain reserved under the configured uniqueness rules.\u003C/p\u003E\n\u003Cp\u003ELimits apply consistently to self-service and administrative identifier additions.\u003C/p\u003E\n\u003Cp\u003EThese rules belong to the Users application layer, keeping persistence implementations independent of application-specific identifier policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E for configuration examples.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-properties\u0022\u003EIdentifier Properties\u003C/h3\u003E\n\u003Cp\u003EThe reference identifier model distinguishes several important properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EType\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifies the kind of identifier, such as email or username\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EValue\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EThe identifier value associated with the user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ENormalized value\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EA representation used for consistent lookup and comparison\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EPrimary state\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndicates the identifier\u0027s designated primary role\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EVerification state\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndicates whether ownership has been verified\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese properties address separate questions.\u003C/p\u003E\n\u003Cp\u003EAn identifier may exist without being verified. A verified identifier does not automatically become primary. A primary identifier does not necessarily satisfy every authentication policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-resolution\u0022\u003ELogin Identifier Resolution\u003C/h3\u003E\n\u003Cp\u003EIdentifier resolution determines how an incoming login identifier is associated with a user identity.\u003C/p\u003E\n\u003Cp\u003EIn the reference approach, primary identifiers define the intended login surface, subject to the configured identifier resolution and verification policies.\u003C/p\u003E\n\u003Cp\u003EApplications can control which identifier types are accepted and how identifiers are resolved.\u003C/p\u003E\n\u003Cp\u003EThis allows authentication to support different identity strategies without embedding email-specific or username-specific assumptions into Core.\u003C/p\u003E\n\u003Cp\u003EThe distinction is important: \u003Cstrong\u003Ean identifier is identity data; whether it is acceptable for login is an authentication policy decision.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-ownership-and-verification\u0022\u003EIdentifier Ownership and Verification\u003C/h3\u003E\n\u003Cp\u003EIdentifier verification is a security operation, not a simple update to a Boolean property.\u003C/p\u003E\n\u003Cp\u003EMarking an identifier as verified must be based on an appropriate ownership proof or an explicitly authorized administrative operation.\u003C/p\u003E\n\u003Cp\u003EThe reference domain therefore distinguishes between:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERegistering an identifier.\u003C/li\u003E\n\u003Cli\u003EEstablishing ownership of an identifier.\u003C/li\u003E\n\u003Cli\u003EVerifying that ownership.\u003C/li\u003E\n\u003Cli\u003EChanging an identifier\u0027s role or status.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAn anonymous or self-service request must not be able to claim verified ownership merely by supplying a verification flag.\u003C/p\u003E\n\u003Cp\u003ELikewise, operations targeting another user\u0027s identifiers must respect the applicable authorization and ownership boundaries.\u003C/p\u003E\n\u003Cp\u003EThis prevents identifier management from becoming an alternative route around authentication security.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profiles\u0022\u003EUser Profiles\u003C/h2\u003E\n\u003Cp\u003EA profile contains information about the user that is not itself an authentication credential or an identity verification proof.\u003C/p\u003E\n\u003Cp\u003ETypical profile information may include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDisplay name and personal information.\u003C/li\u003E\n\u003Cli\u003ELocalization preferences.\u003C/li\u003E\n\u003Cli\u003EApplication metadata.\u003C/li\u003E\n\u003Cli\u003EOther domain-specific user attributes.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUltimateAuth deliberately separates profile data from the security-critical identity model.\u003C/p\u003E\n\u003Cp\u003EApplications should be able to evolve their profile structure without redesigning authentication flows or changing how identifiers are verified.\u003C/p\u003E\n\u003Cp\u003EA profile attribute may still be relevant to application authorization or business rules, but it should not automatically be treated as proof of identity.\u003C/p\u003E\n\u003Cp\u003EThe reference model provides a starting point; applications remain responsible for deciding which additional user attributes belong in their own domain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022batch-user-summary-retrieval\u0022\u003EBatch User Summary Retrieval\u003C/h2\u003E\n\u003Cp\u003EApplications frequently maintain relationships between users without storing complete user profile information alongside those relationships.\u003C/p\u003E\n\u003Cp\u003EFor example, a project management application may store a collection of user keys representing project members.\u003C/p\u003E\n\u003Cp\u003EWhen displaying those members, the application needs their current names, usernames, and other summary information.\u003C/p\u003E\n\u003Cp\u003ERetrieving each user independently can introduce unnecessary requests and repeated persistence operations.\u003C/p\u003E\n\u003Cp\u003EThe Users domain addresses this through batch user summary retrieval.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022iusersummaryquerystore\u0022\u003EIUserSummaryQueryStore\u003C/h3\u003E\n\u003Cp\u003EThe \u003Ccode\u003EIUserSummaryQueryStore\u003C/code\u003E contract supports retrieving user summaries by their keys:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ETask\u0026lt;IReadOnlyList\u0026lt;UserSummary\u0026gt;\u0026gt; GetByKeysAsync(IReadOnlyCollection\u0026lt;UserKey\u0026gt; userKeys, CancellationToken ct = default);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe operation retrieves summaries for a known collection of users within the store\u0027s tenant context.\u003C/p\u003E\n\u003Cp\u003EThe reference implementations include support for Entity Framework Core and InMemory persistence.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-service-and-authorization\u0022\u003EApplication Service and Authorization\u003C/h3\u003E\n\u003Cp\u003EBatch retrieval is exposed through the Users application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EGetUsersByKeysAsync(AccessContext context, IReadOnlyCollection\u0026lt;UserKey\u0026gt; userKeys, CancellationToken ct = default)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe application service coordinates authorization and persistence access.\u003C/p\u003E\n\u003Cp\u003EThe operation is executed through the standard \u003Ccode\u003EAccessOrchestrator\u003C/code\u003E, ensuring that batch retrieval participates in UltimateAuth\u0027s existing authorization pipeline.\u003C/p\u003E\n\u003Cp\u003EThe public Client SDK exposes the functionality through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Users.GetByKeysAsync(userKeys)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022target-aware-access-control\u0022\u003ETarget-Aware Access Control\u003C/h3\u003E\n\u003Cp\u003EA batch request may target multiple users belonging to different groups, organizations, or application-defined security scopes.\u003C/p\u003E\n\u003Cp\u003EFor this reason, UltimateAuth supports multiple target identities in \u003Ccode\u003EAccessContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA policy can inspect the complete target collection before allowing the operation.\u003C/p\u003E\n\u003Cp\u003EThis makes it possible to implement restrictions such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAllowing access only to members of the actor\u0027s organization.\u003C/li\u003E\n\u003Cli\u003ERestricting visibility to users assigned to the same project.\u003C/li\u003E\n\u003Cli\u003ELimiting member directories to a particular group.\u003C/li\u003E\n\u003Cli\u003EDenying requests containing users outside an authorized scope.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe Users domain does not define these application-specific membership rules.\u003C/p\u003E\n\u003Cp\u003EInstead, it provides the batch operation and authorization context needed for applications to enforce them.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/plugin-domains/policies\u0022\u003EPolicies \u0026amp; Access Control\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-appropriate-retrieval-method\u0022\u003EChoosing the Appropriate Retrieval Method\u003C/h3\u003E\n\u003Cp\u003EUse:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EGetUserAsync\u003C/code\u003E for detailed information about one user.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EQueryAsync\u003C/code\u003E for searching, filtering, sorting, and paginating users.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EGetByKeysAsync\u003C/code\u003E for retrieving summaries of a known collection of users.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EBatch retrieval supports up to 250 distinct user keys per request.\u003C/p\u003E\n\u003Cp\u003EIt is intended for targeted retrieval, not as a replacement for paginated user discovery.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022reference-implementation\u0022\u003EReference Implementation\u003C/h2\u003E\n\u003Cp\u003EThe Users Plugin Domain includes a reference implementation that demonstrates and provides a practical user management model.\u003C/p\u003E\n\u003Cp\u003EIt brings together lifecycle, identifiers, and profile management through application services and supporting abstractions.\u003C/p\u003E\n\u003Cp\u003ERather than exposing persistence operations as the primary application interface, the reference implementation places user management rules within the application service layer.\u003C/p\u003E\n\u003Cp\u003EThis layer coordinates operations such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EUser creation and account management.\u003C/li\u003E\n\u003Cli\u003EIdentifier registration and updates.\u003C/li\u003E\n\u003Cli\u003EIdentifier verification and ownership validation.\u003C/li\u003E\n\u003Cli\u003EAccount status changes.\u003C/li\u003E\n\u003Cli\u003EIntegration and lifecycle notifications.\u003C/li\u003E\n\u003Cli\u003ESecurity-related coordination with the authentication infrastructure.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis is an important architectural boundary.\u003C/p\u003E\n\u003Cp\u003EA request to verify an identifier, for example, is not equivalent to directly updating a database field. The application service is responsible for enforcing the rules that make the operation valid.\u003C/p\u003E\n\u003Cp\u003EThe reference implementation provides a recommended starting point for these behaviors without requiring every application to use the same persistence technology or user profile structure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022domain-integration\u0022\u003EDomain Integration\u003C/h2\u003E\n\u003Cp\u003EUser management frequently affects other parts of an identity platform.\u003C/p\u003E\n\u003Cp\u003ECreating a user may be followed by credential enrollment. Changing account security state may require session invalidation. Deleting an account may require coordinated cleanup.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through explicit integration contracts rather than direct dependencies between Plugin Domain implementations.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EIUserLifecycleIntegration\u003C/code\u003E provides an integration boundary for user lifecycle operations.\u003C/p\u003E\n\u003Cp\u003EThis allows other capabilities to participate in user-related workflows while keeping the Users domain responsible for its own state and rules.\u003C/p\u003E\n\u003Cp\u003EThe distinction between domain ownership and cross-domain coordination is central to UltimateAuth\u0027s modular architecture.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022customizing-user-management\u0022\u003ECustomizing User Management\u003C/h2\u003E\n\u003Cp\u003EApplications are not required to accept every detail of the reference implementation.\u003C/p\u003E\n\u003Cp\u003EDepending on their requirements and the available extension contracts, they may:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EProvide custom identifier resolution.\u003C/li\u003E\n\u003Cli\u003EConfigure supported identifier and verification policies.\u003C/li\u003E\n\u003Cli\u003EReplace storage implementations.\u003C/li\u003E\n\u003Cli\u003EIntegrate existing user repositories.\u003C/li\u003E\n\u003Cli\u003EExtend supported lifecycle and application service integrations.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor most applications, the recommended approach is to begin with the reference implementation and customize specific behavior rather than replacing the entire domain.\u003C/p\u003E\n\u003Cp\u003EMore advanced scenarios, including custom domain implementations and integration with existing identity systems, will be covered separately in the customization documentation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-user-model\u0022\u003EChoosing Your User Model\u003C/h2\u003E\n\u003Cp\u003EThere are two broad integration approaches.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EUse the reference Users domain\u003C/strong\u003E when the application needs a ready-made foundation for account lifecycle, identifiers, verification, and user management.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIntegrate an existing identity model\u003C/strong\u003E when the application already owns these responsibilities and needs UltimateAuth primarily for its authentication infrastructure.\u003C/p\u003E\n\u003Cp\u003ENeither approach changes the fundamental security requirements.\u003C/p\u003E\n\u003Cp\u003EIdentity resolution must be trustworthy, identifier ownership must be protected, and authentication state must be managed according to the configured security model.\u003C/p\u003E\n\u003Cp\u003EThe difference is which part of the application owns the user management implementation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cp\u003EThe Users domain establishes identity ownership and account lifecycle, but it does not independently provide every capability of an identity platform.\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/credentials\u0022\u003ECredentials\u003C/a\u003E \u2014 Learn how authentication credentials are managed independently of user identifiers.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/authorization\u0022\u003EAuthorization\u003C/a\u003E \u2014 Explore how identities are associated with permissions and access policies.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains\u0022\u003EPlugin Domains\u003C/a\u003E \u2014 Return to the overall Plugin Domain architecture.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor the distinction between identity and authentication infrastructure, see \u003Ca href=\u0022/docs/fundamentals/auth-model\u0022\u003EAuthentication Model\u003C/a\u003E.\u003C/p\u003E\n", "Headings": [ { "Id": "understanding-user-identity", @@ -33,6 +33,11 @@ "Text": "User Identifiers", "Level": 0 }, + { + "Id": "identifier-capacity-and-limits", + "Text": "Identifier Capacity and Limits", + "Level": 1 + }, { "Id": "identifier-properties", "Text": "Identifier Properties", @@ -104,6 +109,6 @@ "Level": 0 } ], - "SourceHash": "E59EBD0AC1985AD4C0CDAB7844881C58E3DA3EE294DE089B2AE1B7C73A72A0FB", + "SourceHash": "ACF2BFBE24655F8060339AA6CFB29F4E67754306093E36BDECE73B0AD41881A0", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json new file mode 100644 index 00000000..5391c5d7 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/password-policy.json @@ -0,0 +1,94 @@ +{ + "Slug": "security/password-policy", + "Title": "Password Policy \u0026 History", + "Html": "\n\u003Cp\u003EUltimateAuth provides configurable password security policies for applications using its reference password credential implementation.\u003C/p\u003E\n\u003Cp\u003EInstead of requiring every application to implement password validation independently, UltimateAuth provides three built-in policy levels: \u003Cstrong\u003EBasic\u003C/strong\u003E, \u003Cstrong\u003EStandard\u003C/strong\u003E, and \u003Cstrong\u003EStrict\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EEach level defines a starting set of password requirements. Developers can override individual requirements without replacing the entire policy.\u003C/p\u003E\n\u003Cp\u003EPassword validation is enforced on the server. Client applications can retrieve the effective policy to display requirements and provide immediate feedback.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-policy-levels\u0022\u003EPassword Policy Levels\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth includes three built-in presets.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EStandard is the default policy level.\u003C/strong\u003E\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERequirement\u003C/th\u003E\n\u003Cth\u003EBasic\u003C/th\u003E\n\u003Cth\u003EStandard (Default)\u003C/th\u003E\n\u003Cth\u003EStrict\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMinimum length\u003C/td\u003E\n\u003Ctd\u003E8\u003C/td\u003E\n\u003Ctd\u003E8\u003C/td\u003E\n\u003Ctd\u003E15\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMaximum length\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire letter\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire digit\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire uppercase\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire lowercase\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequire special character\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003ENo\u003C/td\u003E\n\u003Ctd\u003EYes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECommon-password detection\u003C/td\u003E\n\u003Ctd\u003EBasic\u003C/td\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003EStrict\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser-similarity detection\u003C/td\u003E\n\u003Ctd\u003EBasic\u003C/td\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003EStrict\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPrevious passwords remembered\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003Ctd\u003E0\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese values are the built-in defaults, not fixed limits for every application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022basic\u0022\u003EBasic\u003C/h3\u003E\n\u003Cp\u003EBasic provides a minimum password length without requiring particular character classes.\u003C/p\u003E\n\u003Cp\u003EIt still enables basic common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EThis preset is intended for applications that prefer fewer composition requirements while retaining server-side checks against predictable passwords.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standard\u0022\u003EStandard\u003C/h3\u003E\n\u003Cp\u003EStandard is the default UltimateAuth password policy.\u003C/p\u003E\n\u003Cp\u003EIt requires at least eight characters, including a letter and a digit, and enables Standard-level common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EApplications do not need to explicitly select Standard unless they want to make their configuration intentional.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022strict\u0022\u003EStrict\u003C/h3\u003E\n\u003Cp\u003EStrict increases the minimum length to 15 characters and requires letters, digits, uppercase letters, lowercase letters, and special characters.\u003C/p\u003E\n\u003Cp\u003EIt also selects Strict-level common-password and user-similarity checks.\u003C/p\u003E\n\u003Cp\u003EStrict is available for applications that require more restrictive password composition rules. It is not automatically the best policy for every application: usability, password-manager compatibility, account recovery, and the application\u0027s threat model should also be considered.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-password-policies\u0022\u003EConfiguring Password Policies\u003C/h2\u003E\n\u003Cp\u003EPassword policies are configured through \u003Ccode\u003EUAuthServerOptions.Password\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022use-a-built-in-preset\u0022\u003EUse a built-in preset\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Credentials.Contracts;\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Strict;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies the Strict preset, including its default length, character requirements, and optional security checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-individual-requirements\u0022\u003ECustomize individual requirements\u003C/h3\u003E\n\u003Cp\u003EYou can override any supported requirement without creating a custom policy implementation.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Credentials.Contracts;\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Standard;\n\n options.Password.MinLength = 12;\n options.Password.MaxLength = 64;\n\n options.Password.RequireUppercase = true;\n options.Password.RequireSpecialCharacter = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective policy starts with Standard and applies the explicitly configured overrides.\u003C/p\u003E\n\u003Cp\u003EUnspecified requirements retain their preset values.\u003C/p\u003E\n\u003Cp\u003EFor example, this configuration still requires a letter and a digit, even though those properties are not explicitly set.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-an-individual-security-check\u0022\u003EDisable an individual security check\u003C/h3\u003E\n\u003Cp\u003ECommon-password detection and user-similarity detection are configured independently from the main preset.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.Level = PasswordPolicyLevel.Standard;\n\n options.Password.CommonPasswords =\n PasswordPolicyLevel.Strict;\n\n options.Password.UserSimilarity =\n PasswordPolicyLevel.Disabled;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis keeps Standard password composition rules, increases common-password detection to Strict, and disables user-similarity detection.\u003C/p\u003E\n\u003Cp\u003EDisabling security checks should be a deliberate application decision.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-length-limits\u0022\u003EPassword Length Limits\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth defines the following limits:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ESetting\u003C/th\u003E\n\u003Cth\u003EValue\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMinimum configurable password length\u003C/td\u003E\n\u003Ctd\u003E6\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDefault maximum password length\u003C/td\u003E\n\u003Ctd\u003E128\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAbsolute input length limit\u003C/td\u003E\n\u003Ctd\u003E256\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe built-in presets use a maximum length of 128 characters.\u003C/p\u003E\n\u003Cp\u003EApplications can customize the configured maximum within the supported limits. The absolute limit is a defensive boundary and is not intended to replace application-level password policy configuration.\u003C/p\u003E\n\u003Cp\u003EInvalid password policy options are rejected during server options validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-password-detection\u0022\u003ECommon-Password Detection\u003C/h2\u003E\n\u003Cp\u003EA password may satisfy every character requirement and still be easy to guess.\u003C/p\u003E\n\u003Cp\u003EFor example, a password such as \u003Ccode\u003Eqwerty123\u003C/code\u003E may satisfy Standard\u0027s letter, digit, and length requirements but still be rejected by the server\u0027s common-password checker.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports independently configurable detection levels:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EDisabled\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBasic\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStandard\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStrict\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe selected level determines the checker configuration used by the reference implementation.\u003C/p\u003E\n\u003Cp\u003ECommon-password detection is a server-side operation. A client-side character validator cannot establish whether a password passes this check.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-similarity-detection\u0022\u003EUser-Similarity Detection\u003C/h2\u003E\n\u003Cp\u003EPasswords that closely resemble account identifiers or other available user information may be predictable.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides user-similarity checking with the same configurable levels:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EDisabled\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EBasic\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStandard\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStrict\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe server evaluates similarity using the available password-policy context.\u003C/p\u003E\n\u003Cp\u003EClient applications should not attempt to reproduce the complete similarity decision from public policy information alone.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022password-history\u0022\u003EPassword History\u003C/h2\u003E\n\u003Cp\u003EPassword history helps prevent users from repeatedly selecting passwords they have used before.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth can remember a configurable number of previous passwords and reject reuse during applicable password credential operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-password-history\u0022\u003EEnable password history\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Password.History.RememberLast = 5;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ERememberLast\u003C/code\u003E controls how many previous passwords are checked.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EValue\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrevious-password history checking is disabled\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u201310\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECheck up to the configured number of previous passwords\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default value is \u003Ccode\u003E0\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe reference password-history validator also checks whether the submitted password matches the current credential, independently of the configured previous-password count.\u003C/p\u003E\n\u003Cp\u003EPassword history compares password candidates against protected password representations; it does not require retaining previous passwords in plaintext.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistence\u0022\u003EPersistence\u003C/h3\u003E\n\u003Cp\u003EPassword history requires an appropriate history store.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides reference storage implementations for in-memory and Entity Framework Core persistence.\u003C/p\u003E\n\u003Cp\u003EFor persistent deployments, apply the required database migrations before enabling password-history functionality.\u003C/p\u003E\n\u003Cp\u003EAn in-memory store is useful for development and testing but does not provide durable history across application restarts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-enforcement-and-client-feedback\u0022\u003EServer Enforcement and Client Feedback\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes between authoritative password validation and client-side guidance.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe server is the security boundary.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EClient applications may retrieve the effective policy and validate its publicly expressible requirements, including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMinimum and maximum length\u003C/li\u003E\n\u003Cli\u003ELetter and digit requirements\u003C/li\u003E\n\u003Cli\u003EUppercase and lowercase requirements\u003C/li\u003E\n\u003Cli\u003ESpecial-character requirements\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther checks remain server-authoritative:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECommon-password detection\u003C/li\u003E\n\u003Cli\u003EUser-similarity detection\u003C/li\u003E\n\u003Cli\u003EPassword history\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA password passing client-side validation is not guaranteed to be accepted by the server.\u003C/p\u003E\n\u003Cp\u003EApplications should handle server-side password-policy violations as expected validation errors, rather than displaying them as unexpected application failures.\u003C/p\u003E\n\u003Cp\u003EFor client API usage and examples, see \u003Ca href=\u0022/docs/client/credentials\u0022\u003EClient SDK \u2014 Credentials\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h2\u003E\n\u003Cp\u003EPassword policies are one part of credential security, not a replacement for the broader authentication system.\u003C/p\u003E\n\u003Cp\u003EApplications should also consider secure password hashing, transport security, login throttling, account recovery, session revocation, and appropriate authorization for credential-management operations.\u003C/p\u003E\n\u003Cp\u003EChanging a password may also affect existing authentication sessions, depending on the configured credential session policy.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E for the relevant session security boundaries.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/plugin-domains/credentials\u0022\u003ECredentials Domain\u003C/a\u003E \u2014 Understand credential ownership, validation, and lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/credentials\u0022\u003EClient SDK \u2014 Credentials\u003C/a\u003E \u2014 Retrieve password requirements and implement credential-management flows.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/real-world-setup\u0022\u003EReal-World Setup\u003C/a\u003E \u2014 Configure UltimateAuth in a real application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Understand the security consequences of credential changes.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Headings": [ + { + "Id": "password-policy-levels", + "Text": "Password Policy Levels", + "Level": 0 + }, + { + "Id": "basic", + "Text": "Basic", + "Level": 1 + }, + { + "Id": "standard", + "Text": "Standard", + "Level": 1 + }, + { + "Id": "strict", + "Text": "Strict", + "Level": 1 + }, + { + "Id": "configuring-password-policies", + "Text": "Configuring Password Policies", + "Level": 0 + }, + { + "Id": "use-a-built-in-preset", + "Text": "Use a built-in preset", + "Level": 1 + }, + { + "Id": "customize-individual-requirements", + "Text": "Customize individual requirements", + "Level": 1 + }, + { + "Id": "disable-an-individual-security-check", + "Text": "Disable an individual security check", + "Level": 1 + }, + { + "Id": "password-length-limits", + "Text": "Password Length Limits", + "Level": 0 + }, + { + "Id": "common-password-detection", + "Text": "Common-Password Detection", + "Level": 0 + }, + { + "Id": "user-similarity-detection", + "Text": "User-Similarity Detection", + "Level": 0 + }, + { + "Id": "password-history", + "Text": "Password History", + "Level": 0 + }, + { + "Id": "enable-password-history", + "Text": "Enable password history", + "Level": 1 + }, + { + "Id": "persistence", + "Text": "Persistence", + "Level": 1 + }, + { + "Id": "server-enforcement-and-client-feedback", + "Text": "Server Enforcement and Client Feedback", + "Level": 0 + }, + { + "Id": "security-considerations", + "Text": "Security Considerations", + "Level": 0 + }, + { + "Id": "next-steps", + "Text": "Next Steps", + "Level": 0 + } + ], + "SourceHash": "2A46CBD9FFD1BA41AD57260E775CF74BA7F5DCD319F1415FDD2F68A6A8AF0BCF", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json new file mode 100644 index 00000000..6ae687ac --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json @@ -0,0 +1,154 @@ +{ + "Slug": "security/rate-limiting", + "Title": "Rate Limiting", + "Html": "\n\u003Cp\u003EUltimateAuth provides built-in HTTP endpoint rate limiting using ASP.NET Core\u0027s native rate limiting infrastructure, together with client-side handling of HTTP 429 responses.\u003C/p\u003E\n\u003Cp\u003ERate limiting helps reduce excessive authentication requests, credential attacks, recovery abuse, and unnecessary session traffic without introducing a separate persistence dependency.\u003C/p\u003E\n\u003Cp\u003EThe server enforces request limits. UltimateAuth clients recognize throttled responses, preserve valid authentication state, and defer selected background operations when appropriate.\u003C/p\u003E\n\u003Cp\u003ENo additional rate limiting package is required.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022getting-started\u0022\u003EGetting started\u003C/h2\u003E\n\u003Cp\u003ERate limiting is enabled by default when using the integrated ASP.NET Core middleware helper.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Standard.PermitLimit = 240;\n options.RateLimiting.Login.PermitLimit = 40;\n\n options.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new()\n {\n PermitLimit = 1200,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the middleware pipeline:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\napp.UseRouting();\n\napp.UseUltimateAuthWithAspNetCore();\n\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EImport \u003Ccode\u003ECodeBeam.UltimateAuth.Server.RateLimiting\u003C/code\u003E for rate limiting rules and endpoint identifiers.\u003C/p\u003E\n\u003Cp\u003EConfigure forwarded headers only when appropriate for the deployment, using explicitly trusted proxies and networks.\u003C/p\u003E\n\u003Cp\u003ERate limiting options are also available through the \u003Ccode\u003EUltimateAuth:Server:RateLimiting\u003C/code\u003E configuration section. The existing server options binding behavior applies, including configuration-file precedence.\u003C/p\u003E\n\u003Cp\u003ERate limiting settings are configured at startup. They are not automatically scoped to individual clients or authentication modes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022middleware-integration\u0022\u003EMiddleware integration\u003C/h2\u003E\n\u003Cp\u003EThe integrated helper exposes a single method with two optional parameters:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic static IApplicationBuilder UseUltimateAuthWithAspNetCore(\n this IApplicationBuilder app,\n bool? enableCors = null,\n bool useRateLimiting = true)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default middleware order is:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ECORS, when enabled or required by UAuthHub\u003C/li\u003E\n\u003Cli\u003EUltimateAuth context middleware\u003C/li\u003E\n\u003Cli\u003EASP.NET Core authentication\u003C/li\u003E\n\u003Cli\u003EASP.NET Core rate limiting\u003C/li\u003E\n\u003Cli\u003EASP.NET Core authorization\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThis places the rate limiter after authentication, allowing authenticated endpoint policies to use validated UltimateAuth actor information.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-examples\u0022\u003EConfiguration examples\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E// Automatic CORS selection; rate limiting enabled\napp.UseUltimateAuthWithAspNetCore();\n\n// Explicit CORS; rate limiting enabled\napp.UseUltimateAuthWithAspNetCore(enableCors: true);\n\n// Automatic CORS; rate limiting disabled\napp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\n// Explicit configuration of both options\napp.UseUltimateAuthWithAspNetCore(enableCors: true, useRateLimiting: true);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EenableCors = null\u003C/code\u003E automatically enables CORS when required by UAuthHub. Setting it to \u003Ccode\u003Efalse\u003C/code\u003E does not remove the host\u0027s responsibility to configure CORS when needed.\u003C/p\u003E\n\u003Cp\u003EThe standalone UltimateAuth middleware helper does not install ASP.NET Core authentication. If authentication has not already established a validated UltimateAuth actor, rate limiting partitioning falls back to the connection IP.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022existing-application-rate-limiters\u0022\u003EExisting application rate limiters\u003C/h3\u003E\n\u003Cp\u003EIf the application already installs its own \u003Ccode\u003EUseRateLimiter()\u003C/code\u003E middleware, disable the helper-owned installation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseRouting();\napp.UseAuthentication();\n\napp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\napp.UseRateLimiter();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis illustrates middleware ownership rather than a universal pipeline layout. Hosts must preserve the required routing, authentication, CORS, and authorization order and avoid duplicate middleware registration.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth cannot detect a separately installed native rate limiting middleware instance.\u003C/p\u003E\n\u003Cp\u003ERepeated calls to the UltimateAuth helper install at most one helper-owned rate limiter on the same application builder. Other middleware installed by the helper is not deduplicated.\u003C/p\u003E\n\u003Cp\u003EAn application builder created with \u003Ccode\u003EApplicationBuilder.New()\u003C/code\u003E has its own middleware installation lifecycle.\u003C/p\u003E\n\u003Cp\u003EDisabling UltimateAuth\u0027s built-in endpoint policies does not disable an application-owned global limiter or native application policies.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022rules-and-default-limits\u0022\u003ERules and default limits\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses native sliding-window limiters with six segments and no request queue.\u003C/p\u003E\n\u003Cp\u003EThe default rule groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERule\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003ETypical operations\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESensitive\u003C/td\u003E\n\u003Ctd\u003E30 requests/minute\u003C/td\u003E\n\u003Ctd\u003ESensitive credential and recovery operations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003E120 requests/minute\u003C/td\u003E\n\u003Ctd\u003EAuthenticated management operations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFrequent\u003C/td\u003E\n\u003Ctd\u003E600 requests/minute\u003C/td\u003E\n\u003Ctd\u003EValidation, refresh, logout, authorization checks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin\u003C/td\u003E\n\u003Ctd\u003E20 requests/minute/IP\u003C/td\u003E\n\u003Ctd\u003EShared credential-verification budget\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are starting defaults, not recommended universal production limits. Adjust them based on application traffic, expected concurrency, and deployment conditions.\u003C/p\u003E\n\u003Cp\u003EGroup rules act as templates for separate endpoint budgets. For example, validation and refresh do not share a counter merely because both use the Frequent group.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022shared-login-budget\u0022\u003EShared login budget\u003C/h3\u003E\n\u003Cp\u003EThe Login rule is shared by:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin\u003C/li\u003E\n\u003Cli\u003ETryLogin\u003C/li\u003E\n\u003Cli\u003EPkceComplete\u003C/li\u003E\n\u003Cli\u003EPkceTryComplete\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese credential-verification operations share the same budget for a partition.\u003C/p\u003E\n\u003Cp\u003EConfigure the shared limit through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Login.PermitLimit = 40;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn explicit endpoint override replaces that endpoint\u0027s rule and separates it from the shared login budget.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022additional-sensitive-defaults\u0022\u003EAdditional sensitive defaults\u003C/h3\u003E\n\u003Cp\u003ECertain operations have stricter defaults than the Sensitive group:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous registration\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous credential reset initiation\u003C/td\u003E\n\u003Ctd\u003E5 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous credential reset completion\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf identifier verification\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese numeric defaults take precedence over the Sensitive group\u0027s numeric settings.\u003C/p\u003E\n\u003Cp\u003EFor example, increasing \u003Ccode\u003ESensitive.PermitLimit\u003C/code\u003E does not automatically increase the anonymous reset initiation limit.\u003C/p\u003E\n\u003Cp\u003EUse an explicit endpoint override to change these limits.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022per-endpoint-configuration\u0022\u003EPer-endpoint configuration\u003C/h2\u003E\n\u003Cp\u003EIndividual endpoint rules can be configured using \u003Ccode\u003ERateLimiting.Endpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, limit session validation to two requests per minute:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n {\n PermitLimit = 2,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe third request within the applicable sliding window receives HTTP 429 when the budget is exhausted.\u003C/p\u003E\n\u003Cp\u003EThis example is useful for testing client behavior, but two validations per minute may be too restrictive for a real application with automatic session validation.\u003C/p\u003E\n\u003Cp\u003EEndpoint overrides are complete replacement rules, not partial changes merged with group numeric settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022policy-precedence\u0022\u003EPolicy precedence\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth resolves endpoint policies in the following order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ENative application endpoint/group rate limiting metadata, including native disable metadata\u003C/li\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003EEndpoints[id]\u003C/code\u003E replacement\u003C/li\u003E\n\u003Cli\u003EShared Login rule for credential-verification operations\u003C/li\u003E\n\u003Cli\u003ESpecial registration, reset, and verification defaults\u003C/li\u003E\n\u003Cli\u003EEndpoint group template\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EAn explicit endpoint override can replace a built-in rule, select an application-owned named policy, or disable the built-in policy for that endpoint.\u003C/p\u003E\n\u003Cp\u003EAll UltimateAuth endpoint mappings have explicit classifications. Endpoint registration fails when a newly mapped endpoint is missing its required classification.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022disabling-rate-limiting\u0022\u003EDisabling rate limiting\u003C/h2\u003E\n\u003Cp\u003ETo disable UltimateAuth\u0027s built-in endpoint policies globally:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Enabled = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETo disable an individual built-in rule:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n{\n Enabled = false\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA disabled UltimateAuth rule does not add native \u003Ccode\u003EDisableRateLimiting\u003C/code\u003E metadata.\u003C/p\u003E\n\u003Cp\u003EApplication-owned policies and global limiters may still apply.\u003C/p\u003E\n\u003Cp\u003ETo prevent the integrated helper from installing the native middleware:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese controls have different responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERateLimiting.Enabled\u003C/code\u003E controls UltimateAuth\u0027s built-in policies.\u003C/li\u003E\n\u003Cli\u003EIndividual rule \u003Ccode\u003EEnabled\u003C/code\u003E settings control specific built-in rules.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EuseRateLimiting\u003C/code\u003E controls whether the integrated helper installs the middleware.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-application-owned-policies\u0022\u003EUsing application-owned policies\u003C/h2\u003E\n\u003Cp\u003EApplications can use their own native ASP.NET Core policies instead of an UltimateAuth built-in rule.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddRateLimiter(options =\u0026gt;\n{\n options.AddPolicy\u0026lt;string, MyRateLimiterPolicy\u0026gt;(\u0026quot;my-auth-policy\u0026quot;);\n});\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.UserCreateAnonymous] = new()\n {\n PolicyName = \u0026quot;my-auth-policy\u0026quot;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENamed policies must be registered with the native rate limiting options used by the middleware.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s built-in policies are inline native policies and do not reserve application policy names.\u003C/p\u003E\n\u003Cp\u003EEndpoint policies do not automatically compose. Native ASP.NET Core endpoint/group precedence applies.\u003C/p\u003E\n\u003Cp\u003ENative \u003Ccode\u003EDisableRateLimiting\u003C/code\u003E metadata also follows ASP.NET Core semantics and can bypass an application global limiter.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022rejection-callbacks\u0022\u003ERejection callbacks\u003C/h3\u003E\n\u003Cp\u003EBuilt-in UltimateAuth inline policies produce their own generic HTTP 429 response.\u003C/p\u003E\n\u003Cp\u003EThey do not invoke the application\u0027s middleware-level \u003Ccode\u003EOnRejected\u003C/code\u003E callback.\u003C/p\u003E\n\u003Cp\u003EApplication-owned named policies can use native rejection behavior. When a named policy has no policy-level rejection callback, the effective native middleware \u003Ccode\u003EOnRejected\u003C/code\u003E callback applies.\u003C/p\u003E\n\u003Cp\u003EIf an application needs complete ownership of rejection responses for a particular endpoint, use an application-owned policy.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not replace application global limiters or application rejection settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-429-responses\u0022\u003EHTTP 429 responses\u003C/h2\u003E\n\u003Cp\u003EWhen a built-in UltimateAuth policy rejects a request, the server returns:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EHTTP status \u003Ccode\u003E429 Too Many Requests\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EProblem Details content\u003C/li\u003E\n\u003Cli\u003EStable detail code \u003Ccode\u003Erate_limited\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECache-Control: no-store\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERetry-After\u003C/code\u003E when the native limiter supplies retry guidance\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA representative response is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;title\u0026quot;: \u0026quot;Too Many Requests\u0026quot;,\n \u0026quot;status\u0026quot;: 429,\n \u0026quot;detail\u0026quot;: \u0026quot;rate_limited\u0026quot;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe exact Problem Details payload may include additional fields.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ERetry-After\u003C/code\u003E is optional. Applications must not assume that every HTTP 429 response contains it.\u003C/p\u003E\n\u003Cp\u003ERejected requests do not execute their endpoint handlers. Consequently, a request rejected at this layer does not consume a reset or PKCE proof, rotate refresh credentials, clear logout cookies, or increment login failure counts through the rejected handler.\u003C/p\u003E\n\u003Cp\u003EAuthentication and other middleware executing before the rate limiter may still perform work.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-facing-messages\u0022\u003EUser-facing messages\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Erate_limited\u003C/code\u003E is a stable machine-readable error code, not a localized user-facing message.\u003C/p\u003E\n\u003Cp\u003EApplications can map it to a message such as:\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EToo many requests. Please wait a moment before trying again.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cp\u003EIf a reliable retry delay is available, the application may display it.\u003C/p\u003E\n\u003Cp\u003EAutomatic background operations should generally handle throttling silently rather than repeatedly notifying the user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-http-429-handling\u0022\u003EClient HTTP 429 handling\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients recognize HTTP 429 as a temporary request throttle.\u003C/p\u003E\n\u003Cp\u003EA 429 response does not establish that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe user\u0027s credentials are invalid\u003C/li\u003E\n\u003Cli\u003EThe session has been revoked\u003C/li\u003E\n\u003Cli\u003EA token has expired\u003C/li\u003E\n\u003Cli\u003EThe user is unauthorized\u003C/li\u003E\n\u003Cli\u003EReauthentication is required\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EA throttled request must not, by itself, clear a valid authenticated session.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth uses its existing client result and error contracts rather than introducing a separate rate limiting exception hierarchy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-result-contracts\u0022\u003EClient result contracts\u003C/h3\u003E\n\u003Cp\u003EThe following contracts carry throttling information where applicable:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthTransportException\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryLoginResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryPkceLoginResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERefreshResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthProblem\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe stable error code is \u003Ccode\u003Erate_limited\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDepending on the contract, it is exposed through \u003Ccode\u003ECode\u003C/code\u003E, \u003Ccode\u003EErrorCode\u003C/code\u003E, or \u003Ccode\u003EProblem.Detail\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHTTP status remains 429.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult.Http.IsTooManyRequests\u003C/code\u003E distinguishes throttling from unauthorized and forbidden results.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ERetryAfter\u003C/code\u003E provides an optional \u003Ccode\u003ETimeSpan\u003C/code\u003E where supported.\u003C/p\u003E\n\u003Cp\u003ETransport DTOs preserve the raw \u003Ccode\u003ERetry-After\u003C/code\u003E header across the JavaScript bridge.\u003C/p\u003E\n\u003Cp\u003EThe existing \u003Ccode\u003EIUAuthTryResult\u003C/code\u003E interface provides default metadata properties so existing implementations are not required to add new members.\u003C/p\u003E\n\u003Cp\u003EThe public \u003Ccode\u003EIFlowClient\u003C/code\u003E method signatures remain unchanged.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-behavior-by-operation\u0022\u003EClient behavior by operation\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EHTTP 429 behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EValidation\u003C/td\u003E\n\u003Ctd\u003EThrows a transport exception with status 429 and throttling metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor authentication state\u003C/td\u003E\n\u003Ctd\u003EPreserves the current identity, claims, and last successful validation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh\u003C/td\u003E\n\u003Ctd\u003EReturns an unsuccessful result with status 429 and \u003Ccode\u003EOutcome = None\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAutomatic refresh\u003C/td\u003E\n\u003Ctd\u003EDefers subsequent attempts without triggering reauthentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin preview\u003C/td\u003E\n\u003Ctd\u003EReturns a distinguishable throttled result rather than invalid credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPKCE preview\u003C/td\u003E\n\u003Ctd\u003EReturns a distinguishable throttled result\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ETry-and-commit\u003C/td\u003E\n\u003Ctd\u003EDoes not submit the final commit after a throttled preview\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout\u003C/td\u003E\n\u003Ctd\u003EDoes not report successful logout or navigate on a confirmed 429\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthorization\u003C/td\u003E\n\u003Ctd\u003EReturns a throttled failure, not a Forbidden result\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProfile, user, credential, and recovery operations\u003C/td\u003E\n\u003Ctd\u003EReturn unsuccessful results without publishing success events\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResource requests\u003C/td\u003E\n\u003Ctd\u003EPreserve HTTP 429 information without changing authentication state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation\u0022\u003EValidation\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E throws \u003Ccode\u003EUAuthTransportException\u003C/code\u003E for HTTP 429 before attempting to deserialize a successful authentication result.\u003C/p\u003E\n\u003Cp\u003EBlazor\u0027s \u003Ccode\u003EUAuthStateManager.EnsureAsync\u003C/code\u003E recognizes this condition and preserves the current authentication snapshot.\u003C/p\u003E\n\u003Cp\u003EIt does not:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EClear the current identity\u003C/li\u003E\n\u003Cli\u003ERemove claims\u003C/li\u003E\n\u003Cli\u003ETreat the user as anonymous\u003C/li\u003E\n\u003Cli\u003EMark the session freshly validated\u003C/li\u003E\n\u003Cli\u003EUpdate the last successful validation timestamp\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESubsequent validation attempts, including forced validation calls, are deferred during the cooldown.\u003C/p\u003E\n\u003Cp\u003EA genuine invalid, expired, or revoked validation result continues to follow the normal state-clearing behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh\u0022\u003ERefresh\u003C/h3\u003E\n\u003Cp\u003EA throttled refresh returns an unsuccessful \u003Ccode\u003ERefreshResult\u003C/code\u003E with:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EStatusCode: 429\nErrorCode: rate_limited\nOutcome: None\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMisleading authentication-outcome headers are ignored on a throttled response.\u003C/p\u003E\n\u003Cp\u003ERepeated refresh calls during the cooldown return the throttled result without issuing another HTTP request.\u003C/p\u003E\n\u003Cp\u003EAutomatic refresh scheduling respects both the configured refresh interval and the throttling deadline.\u003C/p\u003E\n\u003Cp\u003EHTTP 429 does not cause reauthentication events, logout, or automatic navigation.\u003C/p\u003E\n\u003Cp\u003EA genuine refresh HTTP 401 can still produce \u003Ccode\u003EReauthRequired\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-and-pkce\u0022\u003ELogin and PKCE\u003C/h3\u003E\n\u003Cp\u003ELogin and PKCE preview operations expose throttling distinctly from invalid credentials.\u003C/p\u003E\n\u003Cp\u003EWhen a TryAndCommit preview is throttled, the client does not submit the final commit form.\u003C/p\u003E\n\u003Cp\u003EHowever, final browser-navigation form submissions retain their existing navigation semantics.\u003C/p\u003E\n\u003Cp\u003EFor \u003Ccode\u003ELoginAsync\u003C/code\u003E, DirectCommit, \u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E, and the final TryAndCommit submission, the browser owns the resulting HTTP response.\u003C/p\u003E\n\u003Cp\u003EIf that final POST receives HTTP 429, the browser may display the server response page.\u003C/p\u003E\n\u003Cp\u003EThe JavaScript client cannot reliably return the final navigated response\u0027s \u003Ccode\u003ERetry-After\u003C/code\u003E metadata through the existing navigation contract.\u003C/p\u003E\n\u003Cp\u003EPreview success or form submission completion must not be interpreted as confirmation that a session was issued.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003ELogout\u003C/h3\u003E\n\u003Cp\u003ELogout uses an observed form POST so the client can distinguish a confirmed successful response from HTTP 429.\u003C/p\u003E\n\u003Cp\u003EOn HTTP 429:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ENo successful logout event is published.\u003C/li\u003E\n\u003Cli\u003ENo success navigation occurs.\u003C/li\u003E\n\u003Cli\u003EThe client does not clear authentication state solely because of throttling.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOn confirmed success, the existing logout event can update client authentication state.\u003C/p\u003E\n\u003Cp\u003ELogout navigation is different from login navigation: the observed request can follow a successful server redirect and then navigate without replaying the logout POST.\u003C/p\u003E\n\u003Cp\u003ECross-origin logout still requires compatible browser credentials, CORS configuration, and redirect behavior.\u003C/p\u003E\n\u003Cp\u003EA network or redirect failure may leave the server-side logout outcome uncertain. The SDK reports an error rather than claiming success or automatically replaying the operation.\u003C/p\u003E\n\u003Cp\u003EApplications can explicitly revalidate when connectivity permits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-client-transports\u0022\u003ECustom client transports\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EIUAuthRequestClient\u003C/code\u003E supports an observed form request overload:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESendFormAsync(endpoint, form, navigateOnSuccess, ct)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe overload has a default implementation that delegates to the original form request method.\u003C/p\u003E\n\u003Cp\u003EExisting custom transport implementations therefore retain their original interface requirements.\u003C/p\u003E\n\u003Cp\u003EHowever, the default implementation does not provide navigation behavior automatically.\u003C/p\u003E\n\u003Cp\u003ECustom adapters that support logout navigation should implement the observed overload and preserve HTTP status and \u003Ccode\u003ERetry-After\u003C/code\u003E metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retry-after-and-cooldown-behavior\u0022\u003ERetry-After and cooldown behavior\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients support standard \u003Ccode\u003ERetry-After\u003C/code\u003E values expressed as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDelta seconds\u003C/li\u003E\n\u003Cli\u003EHTTP-date values\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EParsing uses invariant culture and UTC.\u003C/p\u003E\n\u003Cp\u003EMissing, malformed, or inaccessible values remain null in application results. Past dates produce a zero retry delay.\u003C/p\u003E\n\u003Cp\u003EFor internal maintenance scheduling, the SDK uses:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA conservative 30-second fallback when retry guidance is unavailable\u003C/li\u003E\n\u003Cli\u003EA one-second minimum to avoid immediate retry loops\u003C/li\u003E\n\u003Cli\u003EBounded timer intervals when waiting for long delays\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ELong retry guidance is honored by the current implementation.\u003C/p\u003E\n\u003Cp\u003EThe SDK does not automatically retry arbitrary application operations.\u003C/p\u003E\n\u003Cp\u003EIn particular, it does not automatically replay login, logout, recovery, credential mutations, or administrative changes after throttling.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin browser clients, the server must expose \u003Ccode\u003ERetry-After\u003C/code\u003E through CORS if JavaScript is expected to read that header.\u003C/p\u003E\n\u003Cp\u003EWhen the header is not accessible, the client follows its missing-header behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-state-safety\u0022\u003EAuthentication state safety\u003C/h2\u003E\n\u003Cp\u003ERate limiting is deliberately separated from authentication validity.\u003C/p\u003E\n\u003Cp\u003EA throttled request does not:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EExtend token expiration\u003C/li\u003E\n\u003Cli\u003ERenew credentials\u003C/li\u003E\n\u003Cli\u003EUpdate successful validation timestamps\u003C/li\u003E\n\u003Cli\u003ECreate a trusted identity\u003C/li\u003E\n\u003Cli\u003ERevoke an existing session\u003C/li\u003E\n\u003Cli\u003EClear browser storage automatically\u003C/li\u003E\n\u003Cli\u003EPublish successful mutation events\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthStateManager\u003C/code\u003E remains responsible for authentication state transitions.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionCoordinator\u003C/code\u003E remains responsible for automatic refresh scheduling and genuine reauthentication handling.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthAuthenticationStateProvider\u003C/code\u003E projects the retained state to Blazor.\u003C/p\u003E\n\u003Cp\u003EExplicit local actions such as \u003Ccode\u003EClear\u003C/code\u003E and \u003Ccode\u003EOnLogoutAsync\u003C/code\u003E, and authoritative revocation or mutation events, retain their existing behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022partitioning-and-trust-boundaries\u0022\u003EPartitioning and trust boundaries\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth chooses rate limiting partitions using trusted server-side information.\u003C/p\u003E\n\u003Cp\u003ECredential entry, anonymous registration and recovery, identifier availability, PKCE, Hub entry, and self-proof verification use the connection IP even when a cookie is present.\u003C/p\u003E\n\u003Cp\u003EOther operations use validated UltimateAuth tenant and actor information when available, otherwise the connection IP.\u003C/p\u003E\n\u003Cp\u003EThe following do not independently establish a trusted authenticated partition:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMere presence of a session cookie\u003C/li\u003E\n\u003Cli\u003EUsernames or target user identifiers\u003C/li\u003E\n\u003Cli\u003EUnvalidated tokens\u003C/li\u003E\n\u003Cli\u003EDevice identifiers\u003C/li\u003E\n\u003Cli\u003EClient profile hints\u003C/li\u003E\n\u003Cli\u003ERaw tenant selectors\u003C/li\u003E\n\u003Cli\u003EPrincipals from unrelated authentication authorities\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAuthenticated actor partitions include validated tenant identity.\u003C/p\u003E\n\u003Cp\u003EAnonymous IP budgets span tenants, preventing a client from multiplying its quota simply by rotating tenant selectors.\u003C/p\u003E\n\u003Cp\u003EIPv4-mapped IPv6 addresses are normalized to IPv4.\u003C/p\u003E\n\u003Cp\u003ERequests without an available connection address share an unknown-address partition.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not trust arbitrary forwarding headers.\u003C/p\u003E\n\u003Cp\u003EConfigure trusted proxies, forwarded headers, and ingress restrictions at the application or infrastructure level.\u003C/p\u003E\n\u003Cp\u003EApplications using another identity authority should configure an appropriate native policy rather than assuming UltimateAuth can validate that authority\u0027s principal.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistence-and-distributed-deployment\u0022\u003EPersistence and distributed deployment\u003C/h2\u003E\n\u003Cp\u003EBuilt-in counters are maintained in application memory.\u003C/p\u003E\n\u003Cp\u003EThey are not stored in UltimateAuth\u0027s persistence providers, EF Core, or a database.\u003C/p\u003E\n\u003Cp\u003EThis means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECounters reset when the application process restarts.\u003C/li\u003E\n\u003Cli\u003EEach application instance maintains its own counters.\u003C/li\u003E\n\u003Cli\u003EBrowser refreshes do not reset server counters.\u003C/li\u003E\n\u003Cli\u003ESliding windows replenish capacity over time.\u003C/li\u003E\n\u003Cli\u003EMultiple application instances do not automatically share a global request budget.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor distributed deployments requiring fleet-wide limits, use an ingress gateway, WAF, or custom native policy backed by shared infrastructure.\u003C/p\u003E\n\u003Cp\u003EBuilt-in native partitions have idle cleanup, but do not provide a hard partition-cardinality bound.\u003C/p\u003E\n\u003Cp\u003ELarge-scale IP rotation, IPv6 address churn, NAT sharing, and botnet traffic require additional operational consideration.\u003C/p\u003E\n\u003Cp\u003ERate limiting does not replace account lockout, proof-attempt limits, anti-enumeration responses, or other authentication security controls.\u003C/p\u003E\n\u003Cp\u003EDirect service calls and Blazor Server circuit operations are outside HTTP endpoint throttling unless the application protects them separately.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022testing-rate-limiting\u0022\u003ETesting rate limiting\u003C/h2\u003E\n\u003Cp\u003EA simple development test is to temporarily limit validation to two requests per minute:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n {\n PermitLimit = 2,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter starting the application and authenticating, send repeated requests to:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EHTTP\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-http\u0022\u003EPOST /auth/validate\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe expected server behavior is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERequest\u003C/th\u003E\n\u003Cth\u003EExpected result\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFirst permitted request\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecond permitted request\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest exceeding the budget\u003C/td\u003E\n\u003Ctd\u003EHTTP 429\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest after capacity replenishes\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EActual results depend on the partition\u0027s existing traffic and sliding-window state.\u003C/p\u003E\n\u003Cp\u003EWhen testing through an UltimateAuth client, the client may defer additional validation requests after receiving HTTP 429. This is expected and should be tested separately from server enforcement.\u003C/p\u003E\n\u003Cp\u003EAlso verify that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAuthentication state remains intact after throttling.\u003C/li\u003E\n\u003Cli\u003EOther independently limited operations continue to work.\u003C/li\u003E\n\u003Cli\u003EAutomatic refresh does not enter an immediate retry loop.\u003C/li\u003E\n\u003Cli\u003ELogin preview does not confuse throttling with invalid credentials.\u003C/li\u003E\n\u003Cli\u003ELogout does not report success after a confirmed 429.\u003C/li\u003E\n\u003Cli\u003ENo successful mutation event is published for a rejected operation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERemove temporary low-limit overrides before deploying to production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxies\u0022\u003EClient IP Address \u0026amp; Reverse Proxies\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for IP-based rate limiting. It does not directly trust forwarded IP headers.\u003C/p\u003E\n\u003Cp\u003EWhen deploying behind Caddy, Nginx, Cloudflare, or another reverse proxy, ensure that ASP.NET Core\u0027s Forwarded Headers Middleware is correctly configured with trusted proxies or networks.\u003C/p\u003E\n\u003Cp\u003EWithout proper configuration, multiple users may be identified by the same proxy IP address and share rate limits.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/getting-started/real-world-setup#10-client-ip-address--reverse-proxy\u0022\u003EClient IP Address \u0026amp; Reverse Proxy Setup\u003C/a\u003E for configuration examples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022related-documentation\u0022\u003ERelated documentation\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/concepts/authentication-model\u0022\u003EAuthentication Model\u003C/a\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERate limiting is one layer of UltimateAuth\u0027s security model. It should be combined with appropriate credential policies, session validation, trusted proxy configuration, and deployment-level protection.\u003C/p\u003E\n", + "Headings": [ + { + "Id": "getting-started", + "Text": "Getting started", + "Level": 0 + }, + { + "Id": "middleware-integration", + "Text": "Middleware integration", + "Level": 0 + }, + { + "Id": "configuration-examples", + "Text": "Configuration examples", + "Level": 1 + }, + { + "Id": "existing-application-rate-limiters", + "Text": "Existing application rate limiters", + "Level": 1 + }, + { + "Id": "rules-and-default-limits", + "Text": "Rules and default limits", + "Level": 0 + }, + { + "Id": "shared-login-budget", + "Text": "Shared login budget", + "Level": 1 + }, + { + "Id": "additional-sensitive-defaults", + "Text": "Additional sensitive defaults", + "Level": 1 + }, + { + "Id": "per-endpoint-configuration", + "Text": "Per-endpoint configuration", + "Level": 0 + }, + { + "Id": "policy-precedence", + "Text": "Policy precedence", + "Level": 1 + }, + { + "Id": "disabling-rate-limiting", + "Text": "Disabling rate limiting", + "Level": 0 + }, + { + "Id": "using-application-owned-policies", + "Text": "Using application-owned policies", + "Level": 0 + }, + { + "Id": "rejection-callbacks", + "Text": "Rejection callbacks", + "Level": 1 + }, + { + "Id": "http-429-responses", + "Text": "HTTP 429 responses", + "Level": 0 + }, + { + "Id": "user-facing-messages", + "Text": "User-facing messages", + "Level": 1 + }, + { + "Id": "client-http-429-handling", + "Text": "Client HTTP 429 handling", + "Level": 0 + }, + { + "Id": "client-result-contracts", + "Text": "Client result contracts", + "Level": 1 + }, + { + "Id": "client-behavior-by-operation", + "Text": "Client behavior by operation", + "Level": 0 + }, + { + "Id": "validation", + "Text": "Validation", + "Level": 1 + }, + { + "Id": "refresh", + "Text": "Refresh", + "Level": 1 + }, + { + "Id": "login-and-pkce", + "Text": "Login and PKCE", + "Level": 1 + }, + { + "Id": "logout", + "Text": "Logout", + "Level": 1 + }, + { + "Id": "custom-client-transports", + "Text": "Custom client transports", + "Level": 1 + }, + { + "Id": "retry-after-and-cooldown-behavior", + "Text": "Retry-After and cooldown behavior", + "Level": 0 + }, + { + "Id": "authentication-state-safety", + "Text": "Authentication state safety", + "Level": 0 + }, + { + "Id": "partitioning-and-trust-boundaries", + "Text": "Partitioning and trust boundaries", + "Level": 0 + }, + { + "Id": "persistence-and-distributed-deployment", + "Text": "Persistence and distributed deployment", + "Level": 0 + }, + { + "Id": "testing-rate-limiting", + "Text": "Testing rate limiting", + "Level": 0 + }, + { + "Id": "client-ip-address-reverse-proxies", + "Text": "Client IP Address \u0026 Reverse Proxies", + "Level": 1 + }, + { + "Id": "related-documentation", + "Text": "Related documentation", + "Level": 0 + } + ], + "SourceHash": "BFDB17C67297EBF0E46C4481A0221ECC54E04A67135EF223BA26DFE64B559B93", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/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 index be947b19..d84cbca3 100644 --- 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 @@ -10,7 +10,7 @@ - + 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 index f03bac21..a699d2cd 100644 --- 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 @@ -183,20 +183,54 @@ h1:focus { transform: scale(1.15); } +/* Vision spotlight β€” Light theme */ .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 ); + transition: opacity 0.65s ease; + background: radial-gradient( ellipse at 50% 50%, rgba(12, 18, 38, 0.08) 0%, rgba(12, 18, 38, 0.32) 48%, rgba(12, 18, 38, 0.72) 100% ); } + /* Vision spotlight β€” Dark theme */ + .ua-global-overlay.ua-overlay-dark { + background: radial-gradient( ellipse at 50% 43%, rgba(120, 140, 255, 0.18) 0%, rgba(85, 95, 210, 0.13) 35%, transparent 72% ), radial-gradient( ellipse at 50% 50%, transparent 15%, rgba(5, 9, 25, 0.16) 65%, rgba(5, 9, 25, 0.42) 100% ); + } + + /* Activate overlay */ .ua-global-overlay.active { opacity: 1; } +/* Vision content stays above the overlay */ +.ua-vision { + position: relative; + z-index: 9999; +} + +/* Bulb glow */ +.ua-vision-icon-wrapper { + position: relative; + z-index: 1; + border-radius: 50%; + transition: transform 0.5s ease, filter 0.5s ease; +} + + .ua-vision-icon-wrapper:hover { + transform: scale(1.18); + filter: drop-shadow(0 0 14px rgba(125, 165, 255, 0.8)) drop-shadow(0 0 42px rgba(125, 165, 255, 0.35)); + } + +/* Accessibility */ +@media (prefers-reduced-motion: reduce) { + .ua-global-overlay, + .ua-vision-icon-wrapper { + transition: none; + } +} + .ua-vision { position: relative; z-index: 9999; diff --git a/global.json b/global.json new file mode 100644 index 00000000..3140116d --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj b/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj index cc80e9e8..d961b7f3 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Sample.Seed diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared.Hub/CodeBeam.UltimateAuth.Sample.Shared.Hub.csproj b/samples/CodeBeam.UltimateAuth.Sample.Shared.Hub/CodeBeam.UltimateAuth.Sample.Shared.Hub.csproj index 5aa2b7e0..967a1f52 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared.Hub/CodeBeam.UltimateAuth.Sample.Shared.Hub.csproj +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared.Hub/CodeBeam.UltimateAuth.Sample.Shared.Hub.csproj @@ -1,9 +1,10 @@ - +ο»Ώ net10.0 enable enable + false @@ -12,7 +13,7 @@ - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/CodeBeam.UltimateAuth.Sample.Shared.csproj b/samples/CodeBeam.UltimateAuth.Sample.Shared/CodeBeam.UltimateAuth.Sample.Shared.csproj index f1d71e85..b1b27775 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/CodeBeam.UltimateAuth.Sample.Shared.csproj +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/CodeBeam.UltimateAuth.Sample.Shared.csproj @@ -1,4 +1,4 @@ - +ο»Ώ net10.0 @@ -13,12 +13,12 @@ - - - - - - + + + + + + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index d88eb0a0..4ec15298 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -1,4 +1,5 @@ -ο»Ώ@inject IUAuthClient UAuthClient +ο»Ώ@using CodeBeam.UltimateAuth.Credentials.Contracts +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @implements IDisposable @@ -106,9 +107,29 @@ Margin="Margin.Dense" Required="true" RequiredError="Password is required." + Immediate="true" + MaxLength="@PasswordPolicyLimits.AbsoluteMaxLength" + Validation="@(new Func(ValidatePassword))" autocomplete="new-password" Disabled="@_isSubmitting" /> + @if (_passwordPolicy is not null && PasswordRequirementsContent is not null) + { + @PasswordRequirementsContent(new PasswordRequirementsContext(_passwordPolicy, _password)) + } + else if (_passwordPolicyLoading) + { + + Loading password requirements... + + } + else + { + + Password requirements are verified by UltimateAuth. + + } +
- -
- - - Password requirements are enforced by the configured UltimateAuth credential policy. - -
@@ -135,10 +149,7 @@ @@ -21,8 +20,7 @@ @if (_isLoading) { - + } else if (_loadError is not null) { @@ -36,9 +34,7 @@
@if (PreviewMode) { - + Preview mode is active. Profile changes cannot be saved. } @@ -51,9 +47,7 @@
- +
Basic Information How this identity appears throughout the application. @@ -65,12 +59,18 @@ Label="First Name" Variant="Variant.Outlined" Margin="Margin.Dense" + MaxLength="@UserProfileLimits.FirstName" + Counter="@UserProfileLimits.FirstName" + Immediate="true" Disabled="@_isSaving" />
@@ -78,6 +78,9 @@ Label="Display Name" Variant="Variant.Outlined" Margin="Margin.Dense" + MaxLength="@UserProfileLimits.DisplayName" + Counter="@UserProfileLimits.DisplayName" + Immediate="true" Disabled="@_isSaving" />
@@ -87,9 +90,7 @@
- +
Personal Details Optional information associated with this profile. @@ -107,6 +108,9 @@ Label="Gender" Variant="Variant.Outlined" Margin="Margin.Dense" + MaxLength="@UserProfileLimits.Gender" + Counter="@UserProfileLimits.Gender" + Immediate="true" Disabled="@_isSaving" />
@@ -115,6 +119,9 @@ Lines="3" Variant="Variant.Outlined" Margin="Margin.Dense" + MaxLength="@UserProfileLimits.Bio" + Counter="@UserProfileLimits.Bio" + Immediate="true" Disabled="@_isSaving" />
@@ -124,9 +131,7 @@
- +
Localization Language, time zone and culture preferences. @@ -139,6 +144,9 @@ Placeholder="e.g. en" Variant="Variant.Outlined" Margin="Margin.Dense" + MaxLength="@UserProfileLimits.Language" + Counter="@UserProfileLimits.Language" + Immediate="true" Disabled="@_isSaving" />
@@ -155,6 +166,9 @@ Variant="Variant.Outlined" Margin="Margin.Dense" SearchBox="true" + MaxLength="@UserProfileLimits.TimeZone" + Counter="@UserProfileLimits.TimeZone" + Immediate="true" Disabled="@_isSaving"> @foreach (var tz in _timeZones) { @@ -173,20 +187,12 @@
- + Cancel - + Save Changes
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor index 34219171..6e81cc6e 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor @@ -36,7 +36,7 @@ Continue - + Switch account diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor index 3850685d..031b5196 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor @@ -36,7 +36,7 @@ Continue - + Switch account diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementsContext.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementsContext.cs new file mode 100644 index 00000000..4d1b21a2 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/PasswordRequirementsContext.cs @@ -0,0 +1,5 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; + +public sealed record PasswordRequirementsContext(UAuthPasswordPolicyInfo Policy, string? Password); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj index afc22ae3..c82d7e9a 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj @@ -9,17 +9,17 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.Designer.cs new file mode 100644 index 00000000..d3d07c21 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.Designer.cs @@ -0,0 +1,729 @@ +ο»Ώ// +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.UAuthHub.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261031185108_AddProfileFieldLimits")] + partial class AddProfileFieldLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .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", "ProfileKey") + .IsUnique(); + + 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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.cs new file mode 100644 index 00000000..64065857 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261031185108_AddProfileFieldLimits.cs @@ -0,0 +1,22 @@ +ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations +{ + /// + public partial class AddProfileFieldLimits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..0bfc6cee --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.Designer.cs @@ -0,0 +1,765 @@ +ο»Ώ// +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.UAuthHub.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261102060746_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .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", "ProfileKey") + .IsUnique(); + + 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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs new file mode 100644 index 00000000..c1baf1b4 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261102060746_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + 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), + CredentialId = table.Column(type: "TEXT", nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index de4aec39..ceee4996 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -244,6 +244,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") @@ -628,6 +664,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Bio") + .HasMaxLength(4096) .HasColumnType("TEXT"); b.Property("BirthDate") @@ -637,24 +674,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Culture") + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("DisplayName") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("FirstName") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Gender") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Language") + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("LastName") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Metadata") @@ -671,6 +714,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("TimeZone") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("UpdatedAt") 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 2012f030..6106524c 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/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 index 9f0beb00..e15aaf63 100644 --- 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 @@ -9,17 +9,17 @@ - - + + 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/Migrations/20261031184917_AddProfileFieldLimits.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261031184917_AddProfileFieldLimits.Designer.cs new file mode 100644 index 00000000..c81fe3d6 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261031184917_AddProfileFieldLimits.Designer.cs @@ -0,0 +1,729 @@ +ο»Ώ// +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("20261031184917_AddProfileFieldLimits")] + partial class AddProfileFieldLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .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", "ProfileKey") + .IsUnique(); + + 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/20261031184917_AddProfileFieldLimits.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261031184917_AddProfileFieldLimits.cs new file mode 100644 index 00000000..31574eaa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261031184917_AddProfileFieldLimits.cs @@ -0,0 +1,22 @@ +ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class AddProfileFieldLimits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs new file mode 100644 index 00000000..3fb236e7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.Designer.cs @@ -0,0 +1,765 @@ +ο»Ώ// +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("20261102060652_003_PasswordHistory")] + partial class _003_PasswordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .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", "ProfileKey") + .IsUnique(); + + 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/20261102060652_003_PasswordHistory.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.cs new file mode 100644 index 00000000..b4a550ae --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261102060652_003_PasswordHistory.cs @@ -0,0 +1,48 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class _003_PasswordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_PasswordHistory", + 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), + CredentialId = table.Column(type: "TEXT", nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 512, nullable: false), + ReplacedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordHistory", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_CredentialId_ReplacedAt_Id", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "CredentialId", "ReplacedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordHistory_Tenant_UserKey", + table: "UAuth_PasswordHistory", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_PasswordHistory"); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index 3e5045d5..8b10c53a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -244,6 +244,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UAuth_PasswordCredentials", (string)null); }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => { b.Property("Id") @@ -628,6 +664,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Bio") + .HasMaxLength(4096) .HasColumnType("TEXT"); b.Property("BirthDate") @@ -637,24 +674,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("Culture") + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("DisplayName") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("FirstName") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Gender") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Language") + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("LastName") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Metadata") @@ -671,6 +714,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("TimeZone") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("UpdatedAt") diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs index 9dc2ee6b..1b1cd3fa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs @@ -78,12 +78,16 @@ builder.Services.AddScopedUltimateAuthSampleSeed(); builder.Services.AddScoped(); -builder.Services.Configure(options => -{ - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedProto; -}); +// Reverse proxy deployments (Caddy, Nginx, etc.): +// Configure forwarded headers only when running behind a trusted proxy. +// +// builder.Services.Configure(options => +// { +// options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +// +// // Replace with the actual trusted proxy address. +// options.KnownProxies.Add(IPAddress.Parse("192.0.2.10")); +// }); var app = builder.Build(); @@ -103,7 +107,10 @@ } await app.SeedUltimateAuthAsync(); } -app.UseForwardedHeaders(); + +// Enable only when trusted forwarded headers are configured. +// Must run before authentication, rate limiting and HTTPS redirection. +// app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm index cec5825e..38ab800e 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal index 80ea6afe..c3298258 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index cfe21912..a582eeab 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,8 +8,8 @@ - - + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index e58f9b28..8ce3a825 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Options; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; @@ -89,12 +91,16 @@ builder.Services.AddScoped(); } -builder.Services.Configure(options => -{ - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedProto; -}); +// Reverse proxy deployments (Caddy, Nginx, etc.): +// Configure forwarded headers only when running behind a trusted proxy. +// +// builder.Services.Configure(options => +// { +// options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +// +// // Replace with the actual trusted proxy address. +// options.KnownProxies.Add(IPAddress.Parse("192.0.2.10")); +// }); var app = builder.Build(); @@ -111,7 +117,9 @@ await app.SeedUltimateAuthAsync(); } -app.UseForwardedHeaders(); +// Enable only when trusted forwarded headers are configured. +// Must run before authentication, rate limiting and HTTPS redirection. +// app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/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 1ef3e632..07ea416a 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 @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj index 1dafa9a8..321b6185 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj index 2154aec8..2afcbaee 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj @@ -11,7 +11,7 @@ - + diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj index cde66c01..3d660c31 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj @@ -8,7 +8,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 cde66c01..3d660c31 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 @@ -8,7 +8,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 37f393b4..95cc2b2a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -38,4 +38,5 @@ public interface ISessionStore Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default); Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index c59475f7..f34b9a2a 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Core diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index e71108bb..8ea5c30b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using System.Collections; namespace CodeBeam.UltimateAuth.Core.Contracts; @@ -31,6 +32,7 @@ public sealed class AccessContext public TenantKey ResourceTenant { get; init; } public string Action { get; init; } = default!; + public UAuthClientProfile ClientProfile { get; } public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; public bool IsCrossTenant => !string.Equals(ActorTenant, ResourceTenant, StringComparison.Ordinal); @@ -62,6 +64,7 @@ internal AccessContext( UserKey? targetUserKey, TenantKey resourceTenant, string action, + UAuthClientProfile clientProfile, IReadOnlyDictionary attributes, IReadOnlyCollection? targetUserKeys = null) { @@ -79,6 +82,7 @@ internal AccessContext( ResourceTenant = resourceTenant; Action = action; + ClientProfile = clientProfile; Attributes = attributes; } @@ -100,6 +104,7 @@ public AccessContext WithAttribute(string key, object value) TargetUserKey, ResourceTenant, Action, + ClientProfile, merged, TargetUserKeys ); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs index daeda5ba..6c1f2cb3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public interface IUAuthTryResult { + int? Status => null; + string? ErrorCode => null; + TimeSpan? RetryAfter => null; bool IsSuccess { get; } AuthFailureReason? Reason { get; } int? RemainingAttempts { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs index a5f692f8..e0c6dcfb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs @@ -7,6 +7,7 @@ public sealed class UAuthProblem public string? Title { get; set; } public int? Status { get; set; } public string? Detail { get; set; } + public TimeSpan? RetryAfter { get; set; } public string? TraceId { get; set; } public Dictionary? Extensions { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index 2a674d30..198d540d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -24,6 +24,7 @@ internal HttpStatusInfo(int status) public bool IsBadRequest => _status == 400; public bool IsUnauthorized => _status == 401; + public bool IsTooManyRequests => _status == 429; public bool IsForbidden => _status == 403; public bool IsConflict => _status == 409; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs index ce08a546..f7e8280f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryLoginResult : IUAuthTryResult { + public int? Status { get; init; } + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs index 6efd7351..c2a8e826 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryPkceLoginResult : IUAuthTryResult { + public int? Status { get; init; } + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs index 3782b0c6..e0b6ed40 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs @@ -13,6 +13,7 @@ public sealed record SessionChainSummary public DateTimeOffset? LastSeenAt { get; init; } public int RotationCount { get; init; } public int TouchCount { get; init; } + public int SessionCount { get; init; } public bool IsRevoked { get; init; } public DateTimeOffset? RevokedAt { get; init; } public AuthSessionId? ActiveSessionId { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 093ec820..439a3973 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -116,6 +116,7 @@ public static class Credentials public const string CompleteResetAnonymous = "credentials.completereset.anonymous"; public const string CompleteResetAdmin = "credentials.completereset.admin"; public const string DeleteAdmin = "credentials.delete.admin"; + public const string GetPasswordPolicyAnonymous = "credentials.getpasswordpolicy.anonymous"; } public static class Authorization diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs index 247b2327..7992a8a8 100644 --- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.Reference")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index 3dd551e3..ae0a47a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -1,5 +1,4 @@ -ο»Ώusing CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.Abstractions; +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -96,6 +95,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, targetUserKey: targetUserKey, resourceTenant: resourceTenant, action: action, + clientProfile: authFlow.ClientProfile, attributes: attrs.Count > 0 ? new ReadOnlyDictionary(attrs) : EmptyAttributes.Instance, diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index fd8d351d..c4425397 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -73,6 +73,8 @@ protected override async Task HandleAuthenticateAsync() if (snapshot is null || snapshot.Identity is null) return AuthenticateResult.NoResult(); + RateLimiting.UAuthRateLimitPolicy.SetValidatedActor(Context, tenant.Value, result.UserKey.Value.Value); + var principal = snapshot.ToClaimsPrincipal(UAuthConstants.SchemeDefaults.GlobalScheme); return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthConstants.SchemeDefaults.GlobalScheme)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs index d1b8fe2f..bea8a3d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; @@ -96,6 +97,7 @@ public static async Task CreateAsync(HttpContext http, string act targetUserKey: null, resourceTenant: tenant, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: attributes); } diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 6f21fab2..6ece6b73 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Server @@ -22,7 +22,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs new file mode 100644 index 00000000..3454b1f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPasswordPolicyEndpointHandler.cs @@ -0,0 +1,8 @@ +ο»Ώusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IPasswordPolicyEndpointHandler +{ + Task GetAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 175b07d3..e2fe123f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -51,6 +51,17 @@ public async Task LogoutAsync(HttpContext ctx) var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); + var observedLogout = string.Equals(ctx.Request.Headers["X-UAuth-Logout-Mode"].ToString(), "observe" ,StringComparison.OrdinalIgnoreCase); + + if (observedLogout) + { + return Results.Ok(new + { + IsSuccess = true, + RedirectUrl = decision.Enabled ? decision.TargetUrl : null + }); + } + return decision.Enabled ? Results.Redirect(decision.TargetUrl!) : Results.Ok(new LogoutResponse { IsSuccess = true }); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs new file mode 100644 index 00000000..6d4ad788 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PasswordPolicyEndpointHandler.cs @@ -0,0 +1,40 @@ +ο»Ώ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class PasswordPolicyEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IAccessOrchestrator accessOrchestrator) : IPasswordPolicyEndpointHandler +{ + public async Task GetAsync(HttpContext context) + { + var flow = authFlow.Current; + + var accessContext = await accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.GetPasswordPolicyAnonymous, + resource: "credentials", + ct: context.RequestAborted); + + var command = new AccessCommand( + ct => + { + ct.ThrowIfCancellationRequested(); + + var effective = PasswordPolicyOptionsResolver.Resolve(flow.Options.Password); + + return Task.FromResult(effective.ToInfo()); + }); + + var info = await accessOrchestrator.ExecuteAsync(accessContext, command, context.RequestAborted); + + context.Response.Headers.CacheControl = "no-store"; + + return Results.Ok(info); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index a2dfbb16..3bf2fe0c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,8 +1,10 @@ -ο»Ώusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Security; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -16,9 +18,12 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { private readonly UAuthServerEndpointOptions _options; + private readonly UAuthAntiforgeryOptions _antiforgeryOptions; + public UAuthEndpointRegistrar(IOptions options) { _options = options.Value.Endpoints; + _antiforgeryOptions = options.Value.Antiforgery; } bool Enabled(string action) => !_options.DisabledActions.Contains(action); @@ -37,6 +42,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options : rootGroup.MapGroup("/" + basePrefix); group.AddEndpointFilter(); + group.AddEndpointFilter(); + + group.MapGet(_antiforgeryOptions.TokenEndpoint, UAuthAntiforgeryEndpoint.GetToken) + .WithUAuthRateLimit(UAuthEndpoint.AntiforgeryToken); + + ((IEndpointConventionBuilder)group).Finally(endpoint => + { + if (!endpoint.Metadata.OfType().Any()) + throw new InvalidOperationException("Every UltimateAuth endpoint must declare a rate limiting classification."); + }); + //var user = group.MapGroup(""); var self = group.MapGroup("/me"); @@ -45,32 +61,34 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.Authentication != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login), new UAuthCsrfMetadata(UAuthCsrfOperation.Login)).WithUAuthRateLimit(UAuthEndpoint.Login); group.MapPost("/try-login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login), new UAuthCsrfMetadata(UAuthCsrfOperation.Login)).WithUAuthRateLimit(UAuthEndpoint.TryLogin); + group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) - => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); + => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)).WithUAuthRateLimit(UAuthEndpoint.Validate); if (Enabled(UAuthActions.Flows.LogoutSelf)) group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.Logout); if (Enabled(UAuthActions.Flows.LogoutDeviceSelf)) self.MapPost("/logout-device", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutDeviceSelf); if (Enabled(UAuthActions.Flows.LogoutOthersSelf)) self.MapPost("/logout-others", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutOthersSelf); if (Enabled(UAuthActions.Flows.LogoutAllSelf)) self.MapPost("/logout-all", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutAllSelf); group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) - => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); + => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.Refresh); + //group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) // => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); @@ -78,15 +96,16 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) admin.MapPost("/users/{userKey}/logout-device", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutDeviceAdmin); if (Enabled(UAuthActions.Flows.LogoutOthersAdmin)) admin.MapPost("/users/{userKey}/logout-others", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutOthersAdmin); if (Enabled(UAuthActions.Flows.LogoutAllAdmin)) admin.MapPost("/users/{userKey}/logout-all", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.LogoutAllAdmin); + } if (options.Endpoints.Pkce != false) @@ -94,13 +113,14 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var pkce = group.MapGroup("/pkce"); pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.PkceAuthorize); pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login), new UAuthCsrfMetadata(UAuthCsrfOperation.PkceCompletion)).WithUAuthRateLimit(UAuthEndpoint.PkceComplete); pkce.MapPost("/try-complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login), new UAuthCsrfMetadata(UAuthCsrfOperation.PkceCompletion)).WithUAuthRateLimit(UAuthEndpoint.PkceTryComplete); + } //if (options.Endpoints.Token != false) @@ -127,48 +147,50 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Sessions.ListChainsSelf)) selfSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetMyChains); if (Enabled(UAuthActions.Sessions.GetChainSelf)) selfSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) - => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetMyChainDetail); if (Enabled(UAuthActions.Sessions.RevokeChainSelf)) selfSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) - => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeMyChain); if (Enabled(UAuthActions.Sessions.RevokeOtherChainsSelf)) selfSession.MapPost("/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeOtherChains); if (Enabled(UAuthActions.Sessions.RevokeAllChainsSelf)) selfSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeAllMyChains); + if (Enabled(UAuthActions.Sessions.ListChainsAdmin)) adminSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetUserChains); if (Enabled(UAuthActions.Sessions.GetChainAdmin)) adminSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) - => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetUserChainDetail); if (Enabled(UAuthActions.Sessions.RevokeSessionAdmin)) adminSession.MapPost("/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) - => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeUserSession); if (Enabled(UAuthActions.Sessions.RevokeChainAdmin)) adminSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) - => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeUserChain); if (Enabled(UAuthActions.Sessions.RevokeRootAdmin)) adminSession.MapPost("/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeRoot); if (Enabled(UAuthActions.Sessions.RevokeAllChainsAdmin)) adminSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RevokeAllChains); + } //if (options.EnableUserInfoEndpoints != false) @@ -189,136 +211,142 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { if (Enabled(UAuthActions.Users.CreateAnonymous)) group.MapPost("/users/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.UserCreateAnonymous); if (Enabled(UAuthActions.Users.ChangeStatusSelf)) self.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.ChangeStatusSelf); if (Enabled(UAuthActions.Users.DeleteSelf)) self.MapPost("/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UserDeleteSelf); + if (Enabled(UAuthActions.Users.QueryAdmin)) adminUsers.MapPost("/query", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.QueryUsers); if (Enabled(UAuthActions.Users.GetByKeysAdmin)) adminUsers.MapPost("/get-by-keys", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetUsersByKeysAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.GetUsersByKeysAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUsersByKeys); if (Enabled(UAuthActions.Users.CreateAdmin)) adminUsers.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAdminAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.CreateAdminAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UserCreateAdmin); if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.ChangeStatusAdmin); if (Enabled(UAuthActions.Users.DeleteAdmin)) adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UserDeleteAdmin); + } if (options.Endpoints.UserProfile != false) { if (Enabled(UAuthActions.UserProfiles.GetSelf)) self.MapPost("/profile/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileGetSelf); if (Enabled(UAuthActions.UserProfiles.CreateSelf)) self.MapPost("/profile/create", async (IUserEndpointHandler h, HttpContext ctx) - => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CreateProfileSelf); if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) self.MapPost("/profile/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.ProfileUpdateSelf); if (Enabled(UAuthActions.UserProfiles.DeleteSelf)) self.MapPost("/profile/delete", async (IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.DeleteProfileSelf); + if (Enabled(UAuthActions.UserProfiles.GetAdmin)) adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileGetAdmin); if (Enabled(UAuthActions.UserProfiles.CreateAdmin)) adminUsers.MapPost("/{userKey}/profile/create", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CreateProfileAdmin); if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.ProfileUpdateAdmin); if (Enabled(UAuthActions.UserProfiles.DeleteAdmin)) adminUsers.MapPost("/{userKey}/profile/delete", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.DeleteProfileAdmin); + } if (options.Endpoints.UserIdentifier != false) { if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) self.MapPost("/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyIdentifiers); if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) self.MapPost("/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.AddUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) self.MapPost("/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UpdateUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.SetPrimarySelf)) self.MapPost("/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.SetPrimaryUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimarySelf)) self.MapPost("/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UnsetPrimaryUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) self.MapPost("/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.VerifyUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) self.MapPost("/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.DeleteUserIdentifierSelf); + if (Enabled(UAuthActions.UserIdentifiers.GetAdmin)) adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUserIdentifiers); if (Enabled(UAuthActions.UserIdentifiers.AddAdmin)) adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.AddUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.UpdateAdmin)) adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UpdateUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.SetPrimaryAdmin)) adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.SetPrimaryUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimaryAdmin)) adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.UnsetPrimaryUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.VerifyAdmin)) adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.VerifyUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.DeleteAdmin)) adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.DeleteUserIdentifierAdmin); + if (Enabled(UAuthActions.UserIdentifiers.CheckAvailability)) self.MapPost("/identifiers/check-availability", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CheckIdentifierAvailabilityAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.CheckIdentifierAvailabilityAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.CheckIdentifierAvailability); } if (options.Endpoints.Credentials != false) @@ -326,50 +354,56 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var selfCredentials = self.MapGroup("/credentials"); var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); + if (Enabled(UAuthActions.Credentials.GetPasswordPolicyAnonymous)) + group.MapPost("/password-policy", async ([FromServices] IPasswordPolicyEndpointHandler h, HttpContext context) + => await h.GetAsync(context)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.PasswordPolicy); + if (Enabled(UAuthActions.Credentials.AddSelf)) selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialAddSelf); if (Enabled(UAuthActions.Credentials.ChangeSelf)) selfCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialChangeSelf); if (Enabled(UAuthActions.Credentials.RevokeSelf)) selfCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialRevokeSelf); + if (Enabled(UAuthActions.Credentials.BeginResetAnonymous)) selfCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetBeginAnonymous); if (Enabled(UAuthActions.Credentials.CompleteResetAnonymous)) selfCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetCompleteAnonymous); if (Enabled(UAuthActions.Credentials.AddAdmin)) adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialAddAdmin); if (Enabled(UAuthActions.Credentials.ChangeAdmin)) adminCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialChangeAdmin); if (Enabled(UAuthActions.Credentials.RevokeAdmin)) adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialRevokeAdmin); if (Enabled(UAuthActions.Credentials.BeginResetAdmin)) adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetBeginAdmin); if (Enabled(UAuthActions.Credentials.CompleteResetAdmin)) adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetCompleteAdmin); if (Enabled(UAuthActions.Credentials.DeleteAdmin)) adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CredentialDeleteAdmin); + } if (options.Endpoints.Authorization != false) @@ -379,48 +413,50 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // TODO: Add enabled actions here selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.AuthorizationCheck); if (Enabled(UAuthActions.Authorization.Roles.GetSelf)) selfAuthz.MapPost("/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyRoles); if (Enabled(UAuthActions.Authorization.Permissions.GetSelf)) selfAuthz.MapPost("/permissions/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.GetMyPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetMyPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyPermissions); if (Enabled(UAuthActions.Authorization.Roles.GetAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUserRoles); if (Enabled(UAuthActions.Authorization.Roles.AssignAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/assign", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.AssignRole); if (Enabled(UAuthActions.Authorization.Roles.RemoveAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/remove", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RemoveRole); if (Enabled(UAuthActions.Authorization.Roles.CreateAdmin)) adminAuthz.MapPost("/roles/create",async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.CreateRole); + if (Enabled(UAuthActions.Authorization.Roles.QueryAdmin)) adminAuthz.MapPost("/roles/query", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.QueryRoles); if (Enabled(UAuthActions.Authorization.Roles.RenameAdmin)) adminAuthz.MapPost("/roles/{roleId}/rename", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.RenameRole); if (Enabled(UAuthActions.Authorization.Roles.SetPermissionsAdmin)) adminAuthz.MapPost("/roles/{roleId}/permissions", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.SetRolePermissions); if (Enabled(UAuthActions.Authorization.Roles.DeleteAdmin)) adminAuthz.MapPost("/roles/{roleId}/delete", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement), new UAuthCsrfMetadata(UAuthCsrfOperation.AuthenticatedMutation)).WithUAuthRateLimit(UAuthEndpoint.DeleteRole); + } // IMPORTANT: diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 8b487a53..fba91eff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Extensions; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; using CodeBeam.UltimateAuth.Policies.Registry; @@ -29,8 +30,11 @@ using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -66,6 +70,7 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s .ValidateOnStart(); services.AddUltimateAuthServerInternal(); + services.AddRateLimiter(_ => { }); return services; } @@ -135,6 +140,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); services.AddSingleton, UAuthServerPaginationOptionsValidator>(); + services.AddSingleton, UAuthServerPasswordPolicyOptionsValidator>(); services.AddScoped(); services.TryAddSingleton(sp => @@ -281,6 +287,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); // ASP.NET Core Integration services.AddAuthentication(); @@ -310,6 +317,32 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol opt.CustomResolversFirst = true; }); + services.AddAntiforgery(options => + { + options.HeaderName = "X-UAuth-CSRF"; + options.FormFieldName = "__RequestVerificationToken"; + }); + + services.AddOptions().PostConfigure>((antiforgery, serverOptions) => + { + var server = serverOptions.Value; + + var sameSite = server.Antiforgery.CookieSameSite + ?? (server.HubDeploymentMode == UAuthHubDeploymentMode.External + ? SameSiteMode.None + : SameSiteMode.Strict); + + antiforgery.Cookie.SameSite = sameSite; + + if (sameSite == SameSiteMode.None) + { + antiforgery.Cookie.SecurePolicy = CookieSecurePolicy.Always; + } + }); + + services.AddScoped(); + services.AddScoped(); + return services; } @@ -369,6 +402,9 @@ internal static IServiceCollection AddCredentialsInternal(IServiceCollection ser { services.TryAddScoped(); services.TryAddScoped(); + + services.AddPasswordStrengthPolicy(); + return services; } @@ -440,7 +476,7 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC services.AddScoped(); services.AddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 5d806c85..fb7c1886 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -10,21 +10,39 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class UltimateAuthApplicationBuilderExtensions { - public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) + /// Installs UltimateAuth context middleware. Optional throttling requires routing first. + /// Do not combine helper-owned throttling with an independent UseRateLimiter call. + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) => + UseUltimateAuth(app, useRateLimiting: false); + + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app, bool useRateLimiting) { app.UseUAuthExceptionHandling(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + if (useRateLimiting) UseOwnedRateLimiter(app); return app; } - public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null) + // Both arguments are required to preserve positional bool calls as the original enableCors argument. + /// + /// Configures the UltimateAuth ASP.NET Core middleware pipeline, including CORS, authentication, rate limiting, and authorization. + /// + /// The application builder. + /// + /// Controls CORS middleware registration. When null, CORS is enabled automatically if required by UAuthHub. + /// + /// + /// Enables UltimateAuth's built-in rate limiting middleware. Defaults to true. + /// + /// + /// The application builder. + /// + public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null, bool useRateLimiting = true) { - var logger = app.ApplicationServices - .GetRequiredService() - .CreateLogger("UltimateAuth"); + var logger = app.ApplicationServices.GetRequiredService().CreateLogger("UltimateAuth"); var marker = app.ApplicationServices.GetService(); var requiresCors = marker?.RequiresCors == true; @@ -39,10 +57,24 @@ public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicatio app.UseUltimateAuth(); app.UseAuthentication(); + + if (useRateLimiting) + UseOwnedRateLimiter(app); + app.UseAuthorization(); + return app; } + private const string OwnedRateLimiterKey = "CodeBeam.UltimateAuth.RateLimiterInstalled"; + + private static void UseOwnedRateLimiter(IApplicationBuilder app) + { + if (app.Properties.TryGetValue(OwnedRateLimiterKey, out var owner) && ReferenceEquals(owner, app)) return; + app.Properties[OwnedRateLimiterKey] = app; + app.UseRateLimiter(); + } + public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBuilder app) { var logger = app.ApplicationServices diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs index 7aef3c12..aa0af9a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs @@ -1,4 +1,5 @@ -ο»Ώusing CodeBeam.UltimateAuth.Server.Infrastructure; +ο»Ώusing CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -9,7 +10,7 @@ public static class UAuthHubEndpointExtensions public static IEndpointRouteBuilder MapUAuthHub(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/auth/uauthhub"); - group.MapPost("/entry", HandleHub.HandleHubEntry); + group.MapPost("/entry", HandleHub.HandleHubEntry).WithUAuthRateLimit(UAuthEndpoint.HubEntry); return endpoints; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs new file mode 100644 index 00000000..4d66c17c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// Forwarded headers are processed by the host, with its explicit proxy trust configuration. +internal static class ClientIpAddress +{ + internal static string Resolve(HttpContext context) + { + var ip = context.Connection.RemoteIpAddress; + if (ip?.IsIPv4MappedToIPv6 == true) ip = ip.MapToIPv4(); + return ip?.ToString() ?? "unknown"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 8fa959b7..38672239 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -// TODO: Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. +// TODO: Consider creating a alternative package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. public sealed class DeviceResolver : IDeviceResolver { private readonly IUserAgentParser _userAgentParser; @@ -40,7 +40,7 @@ public async Task ResolveAsync(HttpContext context) OperatingSystem = parsed.OperatingSystem, Browser = parsed.Browser, UserAgent = ua, - IpAddress = ResolveIp(context) + IpAddress = ClientIpAddress.Resolve(context) }; return deviceInfo; @@ -68,14 +68,4 @@ public async Task ResolveAsync(HttpContext context) return null; } - - private static string? ResolveIp(HttpContext context) - { - var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(forwarded)) - return forwarded.Split(',')[0].Trim(); - - return context.Connection.RemoteIpAddress?.ToString(); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs index 8a72f7d5..6d0c5731 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -1,4 +1,5 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -9,25 +10,34 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class CompositeSessionIdResolver : ISessionIdResolver { private readonly IReadOnlyDictionary _resolvers; - private readonly UAuthSessionResolutionOptions _options; + private readonly IClientProfileReader _clientProfileReader; + private readonly IEffectiveServerOptionsProvider _effectiveOptionsProvider; - public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) + public CompositeSessionIdResolver(IEnumerable resolvers, IClientProfileReader clientProfileReader, IEffectiveServerOptionsProvider effectiveOptionsProvider) { - _options = options.Value.SessionResolution; _resolvers = resolvers.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); + _clientProfileReader = clientProfileReader; + _effectiveOptionsProvider = effectiveOptionsProvider; } - public AuthSessionId? Resolve(HttpContext context) + public async ValueTask ResolveAsync(HttpContext context, CancellationToken ct = default) { - foreach (var name in _options.Order) + ct.ThrowIfCancellationRequested(); + + var clientProfile = await _clientProfileReader.ReadAsync(context); + var effective = _effectiveOptionsProvider.GetEffective(context, clientProfile); + var options = effective.Options.SessionResolution; + + foreach (var name in options.Order) { - if (!IsEnabled(name)) + if (!IsEnabled(name, options)) continue; if (!_resolvers.TryGetValue(name, out var resolver)) continue; var id = resolver.Resolve(context); + if (id is not null) return id; } @@ -35,19 +45,19 @@ public CompositeSessionIdResolver(IEnumerable resolvers return null; } - private bool IsEnabled(string name) + private static bool IsEnabled(string name, UAuthSessionResolutionOptions options) { if (name.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) - return _options.EnableBearer; + return options.EnableBearer; if (name.Equals("Header", StringComparison.OrdinalIgnoreCase)) - return _options.EnableHeader; + return options.EnableHeader; if (name.Equals("Cookie", StringComparison.OrdinalIgnoreCase)) - return _options.EnableCookie; + return options.EnableCookie; if (name.Equals("Query", StringComparison.OrdinalIgnoreCase)) - return _options.EnableQuery; + return options.EnableQuery; return false; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs index f46bbff3..55eec7da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface ISessionIdResolver { - AuthSessionId? Resolve(HttpContext context); + ValueTask ResolveAsync(HttpContext context, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileSafetyValidation.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileSafetyValidation.cs new file mode 100644 index 00000000..1a6b4e48 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileSafetyValidation.cs @@ -0,0 +1,76 @@ +ο»Ώusing System.Text; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public static class UserProfileSafetyValidation +{ + public static IReadOnlyList Validate(UserProfileInfo profile) + { + ArgumentNullException.ThrowIfNull(profile); + + var errors = new List(); + + Check(profile.FirstName, UserProfileLimits.FirstName, "first_name"); + Check(profile.LastName, UserProfileLimits.LastName, "last_name"); + Check(profile.DisplayName, UserProfileLimits.DisplayName, "display_name"); + Check(profile.Gender, UserProfileLimits.Gender, "gender"); + Check(profile.Bio, UserProfileLimits.Bio, "bio"); + Check(profile.Language, UserProfileLimits.Language, "language"); + Check(profile.TimeZone, UserProfileLimits.TimeZone, "time_zone"); + Check(profile.Culture, UserProfileLimits.Culture, "culture"); + + if (profile.Metadata is { } metadata) + { + if (metadata.Count > UserProfileLimits.MetadataEntries) + { + errors.Add(new UAuthValidationError("profile_metadata_entries_exceeded", "metadata")); + } + + long totalBytes = 0; + + foreach (var (key, value) in metadata) + { + if (key.Length > UserProfileLimits.MetadataKey) + { + errors.Add(new UAuthValidationError("profile_metadata_key_too_long" ,"metadata")); + } + + if (value is null || value.Length > UserProfileLimits.MetadataValue) + { + errors.Add(new UAuthValidationError("profile_metadata_value_too_long", "metadata")); + } + + totalBytes += Encoding.UTF8.GetByteCount(key); + + if (value is not null) + totalBytes += Encoding.UTF8.GetByteCount(value); + } + + if (totalBytes > UserProfileLimits.MetadataTotalUtf8Bytes) + { + errors.Add(new UAuthValidationError("profile_metadata_too_large", "metadata")); + } + } + + return errors; + + void Check(string? value, int maxLength, string field) + { + if (value is not null && value.Length > maxLength) + { + errors.Add(new UAuthValidationError("profile_field_too_long", field)); + } + } + } + + public static void EnsureValid(UserProfileInfo profile) + { + var errors = Validate(profile); + + if (errors.Count > 0) + throw new UAuthValidationException(errors[0].Code); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs index 7d9ac809..22d948e7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs @@ -9,6 +9,10 @@ public Task ValidateAsync(AccessContext context, Us { ct.ThrowIfCancellationRequested(); - return Task.FromResult(UserProfileValidationResult.Success()); + var errors = UserProfileSafetyValidation.Validate(profile); + + return Task.FromResult(errors.Count == 0 + ? UserProfileValidationResult.Success() + : UserProfileValidationResult.Failed(errors)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 7e7d94d3..dd7f36ac 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -21,7 +21,7 @@ public async Task InvokeAsync(HttpContext context) var sessionIdResolver = context.RequestServices.GetRequiredService(); var tenant = context.GetTenant(); - var sessionId = sessionIdResolver.Resolve(context); + var sessionId = await sessionIdResolver.ResolveAsync(context); var sessionContext = sessionId is null ? SessionContext.Anonymous() diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 934d33a0..967b1055 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -1,9 +1,6 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.MultiTenancy; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs new file mode 100644 index 00000000..739e4c81 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs @@ -0,0 +1,29 @@ +ο»Ώusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +/// +/// Configures CSRF protection for UltimateAuth endpoints. +/// +public sealed class UAuthAntiforgeryOptions +{ + // TODO: Implement form field and header name configuration for antiforgery token submission. Check uauth.js file carefully to ensure that the form field and header names are consistent with the client-side implementation. + + /// + /// Path segment used to retrieve a request token. + /// Resolved under the configured UltimateAuth base path. + /// + public string TokenEndpoint { get; set; } = "/antiforgery/token"; + + /// + /// Overrides the antiforgery cookie SameSite policy. + /// When null, External Hub deployments use None, other deployments retain ASP.NET Core's Strict default. + /// + public SameSiteMode? CookieSameSite { get; set; } + + public UAuthAntiforgeryOptions Clone() => new() + { + TokenEndpoint = TokenEndpoint, + CookieSameSite = CookieSameSite + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs index cbe7e9ee..68dee600 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs @@ -1,4 +1,6 @@ -ο»Ώnamespace CodeBeam.UltimateAuth.Server.Options; +ο»Ώusing CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthIdentifierBehaviorOptions { @@ -16,6 +18,9 @@ public sealed class UAuthIdentifierBehaviorOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; + public int MaxIdentifiersPerUser { get; set; } = 10; + public Dictionary MaxIdentifiersPerType { get; set; } = new(); + internal UAuthIdentifierBehaviorOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, @@ -26,6 +31,9 @@ public sealed class UAuthIdentifierBehaviorOptions RequireEmailVerification = RequireEmailVerification, RequirePhoneVerification = RequirePhoneVerification, AllowAdminOverride = AllowAdminOverride, - AllowUserOverride = AllowUserOverride + AllowUserOverride = AllowUserOverride, + + MaxIdentifiersPerUser = MaxIdentifiersPerUser, + MaxIdentifiersPerType = new Dictionary(MaxIdentifiersPerType) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 1c04962c..0e8a6e8d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials.Options; using Microsoft.AspNetCore.Routing; namespace CodeBeam.UltimateAuth.Server.Options; @@ -52,6 +53,9 @@ public sealed class UAuthServerOptions // SERVER-ONLY BEHAVIOR // ------------------------------------------------------- + /// Startup-only native endpoint throttling; application policies take precedence. + public RateLimiting.UAuthRateLimitingOptions RateLimiting { get; set; } = new(); + /// /// Defines which authentication modes are allowed to be used by the server. /// This is a safety guardrail, not a mode selection mechanism. @@ -86,6 +90,12 @@ public sealed class UAuthServerOptions /// public UAuthCredentialSessionPolicyOptions CredentialSessionPolicy { get; init; } = new(); + /// + /// Controls password strength, common-password detection, + /// user similarity checks, and password reuse history. + /// + public PasswordPolicyOptions Password { get; set; } = new(); + public UAuthHubOptions Hub { get; set; } = new(); /// @@ -109,12 +119,7 @@ public sealed class UAuthServerOptions public UAuthNavigationOptions Navigation { get; set; } = new(); - - ///// - ///// If true, server will add anti-forgery headers - ///// and require proper request metadata. - ///// - //public bool EnableAntiCsrfProtection { get; set; } = true; + public UAuthAntiforgeryOptions Antiforgery { get; set; } = new(); ///// ///// If true, login attempts are rate-limited to prevent brute force attacks. @@ -144,6 +149,7 @@ internal UAuthServerOptions Clone() AllowedModes = AllowedModes?.ToArray(), HubDeploymentMode = HubDeploymentMode, + RateLimiting = RateLimiting.Clone(), Login = Login.Clone(), Session = Session.Clone(), Token = Token.Clone(), @@ -158,6 +164,7 @@ internal UAuthServerOptions Clone() AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), CredentialSessionPolicy = CredentialSessionPolicy.Clone(), + Password = Password.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), Identifiers = Identifiers.Clone(), @@ -166,6 +173,7 @@ internal UAuthServerOptions Clone() UserProfile = UserProfile.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), + Antiforgery = Antiforgery.Clone(), //EnableAntiCsrfProtection = EnableAntiCsrfProtection, //EnableLoginRateLimiting = EnableLoginRateLimiting, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs index e04e77ad..89caef50 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -7,6 +7,9 @@ public sealed class UAuthServerOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + + if (options is null) + { + return ValidateOptionsResult.Fail("Password policy options cannot be null."); + } + + if (options.Password.Level is not ( + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict)) + { + errors.Add("Password policy Level must be Basic, Standard or Strict."); + } + + if (!IsValidCheckLevel(options.Password.CommonPasswords)) + { + errors.Add("Invalid CommonPasswords level."); + } + + if (!IsValidCheckLevel(options.Password.UserSimilarity)) + { + errors.Add("Invalid UserSimilarity level."); + } + + if (options.Password.History is null) + { + errors.Add("Password History options cannot be null."); + } + else if (options.Password.History.RememberLast < 0 || options.Password.History.RememberLast > PasswordPolicyLimits.MaxHistoryCount) + { + errors.Add("Password history count must be between 0 and 10."); + } + + if (errors.Count > 0) + return ValidateOptionsResult.Fail(errors); + + var effective = PasswordPolicyOptionsResolver.Resolve(options.Password); + + if (effective.MinLength < PasswordPolicyLimits.MinimumLength) + { + errors.Add($"Password MinLength must be at least " + $"{PasswordPolicyLimits.MinimumLength}."); + } + + if (effective.MaxLength > PasswordPolicyLimits.AbsoluteMaxLength) + { + errors.Add($"Password MaxLength cannot exceed " + $"{PasswordPolicyLimits.AbsoluteMaxLength}."); + } + + if (effective.MaxLength < effective.MinLength) + { + errors.Add("Password MaxLength cannot be smaller than MinLength."); + } + + if (effective.MaxLength <= 0) + { + errors.Add("Password MaxLength must be positive."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + private static bool IsValidCheckLevel(PasswordPolicyLevel? level) + { + return level is null or + PasswordPolicyLevel.Disabled or + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs index b66de941..f8fc605e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -6,12 +6,38 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { + var errors = new List(); + if (!options.Identifiers.Behavior.AllowAdminOverride && !options.Identifiers.Behavior.AllowUserOverride) { - return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + + errors.Add("Both AllowAdminOverride and AllowUserOverride cannot be false. " + "At least one actor must be able to manage user identifiers."); } - return ValidateOptionsResult.Success; + var behavior = options.Identifiers.Behavior; + + if (behavior.MaxIdentifiersPerUser < 1) + { + errors.Add("Identifiers.Behavior.MaxIdentifiersPerUser must be greater than zero."); + } + + if (behavior.MaxIdentifiersPerType is null) + { + errors.Add("Identifiers.Behavior.MaxIdentifiersPerType cannot be null."); + } + else + { + foreach (var (type, limit) in behavior.MaxIdentifiersPerType) + { + if (!Enum.IsDefined(type) || limit < 1) + { + errors.Add($"Invalid identifier limit for {type}. Limit must be greater than zero."); + } + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); } } diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs new file mode 100644 index 00000000..f440185b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpoint.cs @@ -0,0 +1,89 @@ +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +/// Stable identities of the built-in HTTP endpoints. +public enum UAuthEndpoint +{ + Login, + TryLogin, + Validate, + Logout, + LogoutDeviceSelf, + LogoutOthersSelf, + LogoutAllSelf, + Refresh, + LogoutDeviceAdmin, + LogoutOthersAdmin, + LogoutAllAdmin, + PkceAuthorize, + PkceComplete, + PkceTryComplete, + GetMyChains, + GetMyChainDetail, + RevokeMyChain, + RevokeOtherChains, + RevokeAllMyChains, + GetUserChains, + GetUserChainDetail, + RevokeUserSession, + RevokeUserChain, + RevokeRoot, + RevokeAllChains, + UserCreateAnonymous, + ChangeStatusSelf, + UserDeleteSelf, + QueryUsers, + GetUsersByKeys, + UserCreateAdmin, + ChangeStatusAdmin, + UserDeleteAdmin, + ProfileGetSelf, + CreateProfileSelf, + ProfileUpdateSelf, + DeleteProfileSelf, + ProfileGetAdmin, + CreateProfileAdmin, + ProfileUpdateAdmin, + DeleteProfileAdmin, + GetMyIdentifiers, + AddUserIdentifierSelf, + UpdateUserIdentifierSelf, + SetPrimaryUserIdentifierSelf, + UnsetPrimaryUserIdentifierSelf, + VerifyUserIdentifierSelf, + DeleteUserIdentifierSelf, + GetUserIdentifiers, + AddUserIdentifierAdmin, + UpdateUserIdentifierAdmin, + SetPrimaryUserIdentifierAdmin, + UnsetPrimaryUserIdentifierAdmin, + VerifyUserIdentifierAdmin, + DeleteUserIdentifierAdmin, + CheckIdentifierAvailability, + CredentialAddSelf, + CredentialChangeSelf, + CredentialRevokeSelf, + CredentialResetBeginAnonymous, + CredentialResetCompleteAnonymous, + CredentialAddAdmin, + CredentialChangeAdmin, + CredentialRevokeAdmin, + CredentialResetBeginAdmin, + CredentialResetCompleteAdmin, + CredentialDeleteAdmin, + AuthorizationCheck, + GetMyRoles, + GetMyPermissions, + GetUserRoles, + AssignRole, + RemoveRole, + CreateRole, + QueryRoles, + RenameRole, + SetRolePermissions, + DeleteRole, + HubEntry, + PasswordPolicy, + AntiforgeryToken, +} + +internal enum UAuthRateLimitGroup { Sensitive, Standard, Frequent } diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs new file mode 100644 index 00000000..cae51dc2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs @@ -0,0 +1,92 @@ +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +internal sealed record UAuthRateLimitMetadata(UAuthEndpoint Endpoint, UAuthRateLimitGroup Group, bool IpOnly); + +internal static class UAuthEndpointClassification +{ + internal static UAuthRateLimitMetadata Get(UAuthEndpoint endpoint) => endpoint switch + { + UAuthEndpoint.Login => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.TryLogin => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.Validate => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.Logout => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.LogoutDeviceSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutOthersSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutAllSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.Refresh => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.LogoutDeviceAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutOthersAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutAllAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.PkceAuthorize => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PkceComplete => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PkceTryComplete => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.GetMyChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyChainDetail => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeMyChain => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeOtherChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeAllMyChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserChainDetail => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeUserSession => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeUserChain => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeRoot => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeAllChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserCreateAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.ChangeStatusSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserDeleteSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.QueryUsers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUsersByKeys => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserCreateAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ChangeStatusAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserDeleteAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileGetSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateProfileSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileUpdateSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteProfileSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileGetAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateProfileAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileUpdateAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteProfileAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyIdentifiers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AddUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.UpdateUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.SetPrimaryUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.UnsetPrimaryUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.VerifyUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.DeleteUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.GetUserIdentifiers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AddUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UpdateUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.SetPrimaryUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UnsetPrimaryUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.VerifyUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CheckIdentifierAvailability => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialAddSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialChangeSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialRevokeSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialResetBeginAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialResetCompleteAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialAddAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialChangeAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialRevokeAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialResetBeginAdmin => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialResetCompleteAdmin => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialDeleteAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AuthorizationCheck => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.GetMyRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyPermissions => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AssignRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RemoveRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.QueryRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RenameRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.SetRolePermissions => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.HubEntry => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PasswordPolicy => new(endpoint, UAuthRateLimitGroup.Standard, true), + UAuthEndpoint.AntiforgeryToken => new(endpoint, UAuthRateLimitGroup.Frequent, false), + _ => throw new ArgumentOutOfRangeException(nameof(endpoint)) + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs new file mode 100644 index 00000000..ff2ca672 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +internal readonly record struct UAuthPartitionKey(string Budget, string Identity); + +internal sealed class UAuthRateLimitPolicy : IRateLimiterPolicy +{ + private static readonly object ActorKey = new(); + private readonly UAuthRateLimitMetadata _metadata; + private readonly UAuthRateLimitRule _rule; + private readonly string _budget; + + internal UAuthRateLimitPolicy(UAuthRateLimitMetadata metadata, UAuthRateLimitRule rule, string budget) + { + _metadata = metadata; _rule = rule.Clone(); _budget = budget; + } + + internal static void SetValidatedActor(HttpContext context, string tenant, string user) => + context.Items[ActorKey] = (tenant, user); + + public RateLimitPartition GetPartition(HttpContext context) + { + var identity = !_metadata.IpOnly && context.Items.TryGetValue(ActorKey, out var actor) + && actor is ValueTuple validated + ? $"user:{validated.Item1.Length}:{validated.Item1}:{validated.Item2}" + : "ip:" + Infrastructure.ClientIpAddress.Resolve(context); + return RateLimitPartition.GetSlidingWindowLimiter(new UAuthPartitionKey(_budget, identity), _ => new() + { + PermitLimit = _rule.PermitLimit, + Window = _rule.Window, + SegmentsPerWindow = 6, + QueueLimit = 0, + AutoReplenishment = true + }); + } + + public Func? OnRejected => RejectAsync; + + private async ValueTask RejectAsync(OnRejectedContext rejected, CancellationToken cancellationToken) + { + var response = rejected.HttpContext.Response; + response.StatusCode = StatusCodes.Status429TooManyRequests; + response.Headers.CacheControl = "no-store"; + if (rejected.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry)) + response.Headers.RetryAfter = Math.Max(1, Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + await Results.Problem(statusCode: 429, title: "Too many requests", + detail: "rate_limited", extensions: new Dictionary + { ["traceId"] = rejected.HttpContext.TraceIdentifier }).ExecuteAsync(rejected.HttpContext); + } +} + +internal static class UAuthRateLimitEndpointExtensions +{ + internal static T WithUAuthRateLimit(this T builder, UAuthEndpoint endpoint) where T : IEndpointConventionBuilder + { + var metadata = UAuthEndpointClassification.Get(endpoint); + builder.Add(b => b.Metadata.Add(metadata)); + builder.Finally(b => + { + // Includes policies inherited from application route groups. Native Disable also wins. + if (b.Metadata.OfType().Any() || b.Metadata.OfType().Any()) + return; + var options = b.ApplicationServices.GetRequiredService>().Value.RateLimiting; + var (rule, budget) = options.Resolve(metadata); + + if (!options.Enabled || !rule.Enabled) + return; + + var immediate = new ImmediateConventionBuilder(b); + + if (rule.PolicyName is not null) + immediate.RequireRateLimiting(rule.PolicyName); + else + immediate.RequireRateLimiting(new UAuthRateLimitPolicy(metadata, rule, budget)); + }); + return builder; + } + + // Uses the public native extension to build inline-policy metadata during finalization. + private sealed class ImmediateConventionBuilder(EndpointBuilder endpoint) : IEndpointConventionBuilder + { + public void Add(Action convention) => convention(endpoint); + } +} + +internal static class UAuthRateLimitValidation +{ + internal static string? Validate(UAuthRateLimitingOptions? options) + { + if (options is null || options.Endpoints is null) return "RateLimiting and its Endpoints cannot be null."; + if (options.Endpoints.Keys.Any(key => !Enum.IsDefined(typeof(UAuthEndpoint), key))) + return "RateLimiting.Endpoints contains an unknown endpoint."; + foreach (var rule in new[] { options.Sensitive, options.Standard, options.Frequent, options.Login }.Concat(options.Endpoints.Values)) + { + if (rule is null) + return "RateLimiting rules cannot be null."; + + if (rule.PolicyName is not null) + { + if (string.IsNullOrWhiteSpace(rule.PolicyName)) + return "RateLimiting.PolicyName cannot be blank."; + } + else if (rule.PermitLimit <= 0 || rule.Window.Ticks < 6) + return "RateLimiting requires a positive PermitLimit and a window of at least six ticks."; + } + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs new file mode 100644 index 00000000..158aa062 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs @@ -0,0 +1,57 @@ +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +/// Startup-only endpoint rate limiting configuration. Counters are process-local. +public sealed class UAuthRateLimitingOptions +{ + public bool Enabled { get; set; } = true; + public UAuthRateLimitRule Sensitive { get; set; } = new() { PermitLimit = 30 }; + public UAuthRateLimitRule Standard { get; set; } = new() { PermitLimit = 120 }; + public UAuthRateLimitRule Frequent { get; set; } = new() { PermitLimit = 600 }; + /// Shared by Login, TryLogin, PkceComplete and PkceTryComplete unless explicitly overridden. + public UAuthRateLimitRule Login { get; set; } = new() { PermitLimit = 20 }; + /// Complete replacement rules. Disabling removes only UltimateAuth's contribution. + public Dictionary Endpoints { get; set; } = new(); + + internal (UAuthRateLimitRule Rule, string Budget) Resolve(UAuthRateLimitMetadata metadata) + { + if (Endpoints.TryGetValue(metadata.Endpoint, out var rule)) + return (rule, metadata.Endpoint.ToString()); + if (metadata.Endpoint is UAuthEndpoint.Login or UAuthEndpoint.TryLogin or UAuthEndpoint.PkceComplete or UAuthEndpoint.PkceTryComplete) + return (Login, "login-verification"); + rule = metadata.Group switch + { + UAuthRateLimitGroup.Sensitive => Sensitive, + UAuthRateLimitGroup.Standard => Standard, + _ => Frequent + }; + // Explicit endpoint overrides replace these security defaults as well. + rule = metadata.Endpoint switch + { + UAuthEndpoint.UserCreateAnonymous => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + UAuthEndpoint.CredentialResetBeginAnonymous => new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + UAuthEndpoint.CredentialResetCompleteAnonymous or UAuthEndpoint.VerifyUserIdentifierSelf => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + _ => rule + }; + return (rule, metadata.Endpoint.ToString()); + } + + internal UAuthRateLimitingOptions Clone() => new() + { + Enabled = Enabled, + Sensitive = Sensitive.Clone(), + Standard = Standard.Clone(), + Frequent = Frequent.Clone(), + Login = Login.Clone(), + Endpoints = Endpoints.ToDictionary(x => x.Key, x => x.Value.Clone()) + }; +} + +public sealed class UAuthRateLimitRule +{ + public bool Enabled { get; set; } = true; + public int PermitLimit { get; set; } = 30; + public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); + /// Optional application-owned named native policy; numeric settings are unused. Native policy/middleware rejection precedence applies. Built-in inline policies otherwise own their 429 response. + public string? PolicyName { get; set; } + internal UAuthRateLimitRule Clone() => (UAuthRateLimitRule)MemberwiseClone(); +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs index 14429d4c..ac1f2f71 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -36,6 +36,11 @@ public async Task ValidateSessionAsync(SessionValidatio request.Headers.Add("Cookie", cookie.ToString()); } + if (context.Device.DeviceId is { } deviceId) + { + request.Headers.TryAddWithoutValidation("X-UDID", deviceId.Value); + } + var response = await _http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceCompositionSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceCompositionSessionIdResolver.cs new file mode 100644 index 00000000..cd986c9a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceCompositionSessionIdResolver.cs @@ -0,0 +1,54 @@ +ο»Ώ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 ResourceCompositeSessionIdResolver : ISessionIdResolver +{ + private readonly IReadOnlyDictionary _resolvers; + private readonly UAuthSessionResolutionOptions _options; + + public ResourceCompositeSessionIdResolver(IEnumerable resolvers, IOptions options) + { + _options = options.Value.SessionResolution; + _resolvers = resolvers.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); + } + + public ValueTask ResolveAsync(HttpContext context, CancellationToken ct = default) + { + 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 ValueTask.FromResult(id); + } + + return ValueTask.FromResult(null); + } + + private bool IsEnabled(string name) + { + if (name.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + return _options.EnableBearer; + + if (name.Equals("Header", StringComparison.OrdinalIgnoreCase)) + return _options.EnableHeader; + + if (name.Equals("Cookie", StringComparison.OrdinalIgnoreCase)) + return _options.EnableCookie; + + if (name.Equals("Query", StringComparison.OrdinalIgnoreCase)) + return _options.EnableQuery; + + return false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthAntiforgeryEndpoint.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthAntiforgeryEndpoint.cs new file mode 100644 index 00000000..380bb729 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthAntiforgeryEndpoint.cs @@ -0,0 +1,24 @@ +ο»Ώusing Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal static class UAuthAntiforgeryEndpoint +{ + public static IResult GetToken(HttpContext context, IAntiforgery antiforgery) + { + context.Response.Headers.CacheControl = "no-store, no-cache"; + + context.Response.Headers.Pragma = "no-cache"; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + + var tokens = antiforgery.GetAndStoreTokens(context); + + return Results.Ok(new + { + requestToken = tokens.RequestToken, + headerName = tokens.HeaderName, + formFieldName = tokens.FormFieldName + }); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfEndpointFilter.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfEndpointFilter.cs new file mode 100644 index 00000000..39cca5e8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfEndpointFilter.cs @@ -0,0 +1,35 @@ +ο»Ώusing CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class UAuthCsrfEndpointFilter(IAuthFlowContextAccessor authContext, UAuthCsrfPolicyEvaluator policy, UAuthCsrfValidator validator) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var http = context.HttpContext; + + var metadata = http.GetEndpoint()?.Metadata.GetMetadata(); + + if (metadata is null) + return await next(context); + + var auth = authContext.Current; + + if (!policy.RequiresProtection(auth, metadata.Operation)) + { + return await next(context); + } + + if (!await validator.ValidateAsync(http)) + { + return Results.BadRequest( + new + { + error = "invalid_antiforgery_token" + }); + } + + return await next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfMetadata.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfMetadata.cs new file mode 100644 index 00000000..155bcac2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfMetadata.cs @@ -0,0 +1,3 @@ +ο»Ώusing CodeBeam.UltimateAuth.Server.Security; + +internal sealed record UAuthCsrfMetadata(UAuthCsrfOperation Operation); diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfOperation.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfOperation.cs new file mode 100644 index 00000000..d419cd91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfOperation.cs @@ -0,0 +1,8 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Server.Security; + +public enum UAuthCsrfOperation +{ + Login = 0, + PkceCompletion = 10, + AuthenticatedMutation = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfPolicyEvaluator.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfPolicyEvaluator.cs new file mode 100644 index 00000000..e506e770 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfPolicyEvaluator.cs @@ -0,0 +1,26 @@ +ο»Ώusing CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class UAuthCsrfPolicyEvaluator +{ + public bool RequiresProtection(AuthFlowContext auth, UAuthCsrfOperation operation) + { + return RequiresProtection(operation, + UAuthCsrfResponseInspector.CanIssueAuthenticationCookie(auth.Response), + auth.Options.SessionResolution.EnableCookie); + } + + internal static bool RequiresProtection(UAuthCsrfOperation operation, bool canIssueCookie, bool canAcceptCookie) + { + return operation switch + { + UAuthCsrfOperation.Login + or UAuthCsrfOperation.PkceCompletion + or UAuthCsrfOperation.AuthenticatedMutation + => canIssueCookie || canAcceptCookie, + + _ => true + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfResponseInspector.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfResponseInspector.cs new file mode 100644 index 00000000..8a92cd23 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfResponseInspector.cs @@ -0,0 +1,14 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal static class UAuthCsrfResponseInspector +{ + public static bool CanIssueAuthenticationCookie(EffectiveAuthResponse response) + { + return response.SessionIdDelivery.Mode == TokenResponseMode.Cookie + || response.AccessTokenDelivery.Mode == TokenResponseMode.Cookie + || response.RefreshTokenDelivery.Mode == TokenResponseMode.Cookie; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfTransport.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfTransport.cs new file mode 100644 index 00000000..632927b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfTransport.cs @@ -0,0 +1,18 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Server.Security; + +internal readonly record struct UAuthCsrfTransport(bool CanIssueAuthenticationCookie, bool CanConsumeAuthenticationCookie, bool IsVerifiedHeaderOnly) +{ + public bool RequiresProtection(UAuthCsrfOperation operation) + { + return operation switch + { + UAuthCsrfOperation.Login or UAuthCsrfOperation.PkceCompletion + => CanIssueAuthenticationCookie, + + UAuthCsrfOperation.AuthenticatedMutation + => CanConsumeAuthenticationCookie || CanIssueAuthenticationCookie, + + _ => true + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfValidator.cs b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfValidator.cs new file mode 100644 index 00000000..7ba7d563 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Security/UAuthCsrfValidator.cs @@ -0,0 +1,20 @@ +ο»Ώusing Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class UAuthCsrfValidator(IAntiforgery antiforgery) +{ + public async Task ValidateAsync(HttpContext context) + { + try + { + await antiforgery.ValidateRequestAsync(context); + return true; + } + catch (AntiforgeryValidationException) + { + return false; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index ff3b6e77..9145b450 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -80,9 +80,10 @@ public async Task> GetUserChainsAsync(AccessCon var total = chains.Count; - var pageItems = chains - .Skip((request.PageNumber - 1) * request.PageSize) - .Take(request.PageSize) + var pageChains = chains.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); + var sessionCounts = await store.GetSessionCountsByChainsAsync(pageChains.Select(c => c.ChainId).ToArray(), innerCt); + + var pageItems = pageChains .Select(c => new SessionChainSummary { ChainId = c.ChainId, @@ -94,11 +95,13 @@ public async Task> GetUserChainsAsync(AccessCon LastSeenAt = c.LastSeenAt, RotationCount = c.RotationCount, TouchCount = c.TouchCount, + SessionCount = sessionCounts.GetValueOrDefault(c.ChainId), IsRevoked = c.IsRevoked, RevokedAt = c.RevokedAt, ActiveSessionId = c.ActiveSessionId, - IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value, - State = c.State, + IsCurrentDevice = + actorChainId.HasValue && c.ChainId == actorChainId.Value, + State = c.State }) .ToList(); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index c46cceaa..ad9617f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -16,7 +16,6 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi _claimsProvider = claimsProvider; } - // 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 @@ -61,21 +60,13 @@ public async Task ValidateSessionAsync(SessionValidatio if (session.SecurityVersionAtCreation != root.SecurityVersion) return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); - if (chain.Device.HasDeviceId && context.Device.HasDeviceId) - { - if (!Equals(chain.Device.DeviceId, context.Device.DeviceId)) - { - if (options.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + // TODO: Implement device mismatch behavior based on options, e.g., allow, reject or rebind. + var deviceMismatch = !chain.Device.HasDeviceId || !context.Device.HasDeviceId || !Equals(chain.Device.DeviceId, context.Device.DeviceId); - //if (options.Session.DeviceMismatchBehavior == AllowAndRebind) - } + if (deviceMismatch && options.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + { + return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); } - //else - //{ - // // Add SessionValidatorOrigin to seperate UserRequest or background task - // return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); - //} var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId); diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj index 4437d969..ceab34cf 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore 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 c47f0f33..59cd70f1 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authentication.InMemory 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 46f73334..7911a019 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authorization.Contracts 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 76cfca96..f85a3966 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore 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 a7723fcd..627076aa 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authorization.InMemory 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 82976ca3..38c3e64f 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authorization.Reference diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj index 00dad5a1..d83ec60d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Authorization diff --git a/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj index 17220e06..9926c359 100644 --- a/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj +++ b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle diff --git a/src/bundle/CodeBeam.UltimateAuth.InMemory.Bundle/CodeBeam.UltimateAuth.InMemory.Bundle.csproj b/src/bundle/CodeBeam.UltimateAuth.InMemory.Bundle/CodeBeam.UltimateAuth.InMemory.Bundle.csproj index c0c22ad8..a64f2137 100644 --- a/src/bundle/CodeBeam.UltimateAuth.InMemory.Bundle/CodeBeam.UltimateAuth.InMemory.Bundle.csproj +++ b/src/bundle/CodeBeam.UltimateAuth.InMemory.Bundle/CodeBeam.UltimateAuth.InMemory.Bundle.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.InMemory.Bundle diff --git a/src/bundle/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj b/src/bundle/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj index 01f6356a..48919bb1 100644 --- a/src/bundle/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj +++ b/src/bundle/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Reference.Bundle diff --git a/src/client/CodeBeam.UltimateAuth.Client.AspNetCore/CodeBeam.UltimateAuth.Client.AspNetCore.csproj b/src/client/CodeBeam.UltimateAuth.Client.AspNetCore/CodeBeam.UltimateAuth.Client.AspNetCore.csproj index a6b3ab90..fcb04456 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.AspNetCore/CodeBeam.UltimateAuth.Client.AspNetCore.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.AspNetCore/CodeBeam.UltimateAuth.Client.AspNetCore.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Client.AspNetCore diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj index 73d5c800..a2e8969e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) CodeBeam.UltimateAuth.Client.Blazor @@ -16,25 +16,25 @@ - - - - + + + + - - - - + + + + - - - - + + + + - + 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 7ff71a56..d9c54663 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs @@ -95,6 +95,13 @@ public partial class UAuthLoginForm [Parameter] public EventCallback OnTryResult { get; set; } + private bool _isSubmitting; + + [Parameter] + public EventCallback OnSubmittingChanged { get; set; } + + public bool IsSubmitting => _isSubmitting; + private ElementReference _form; private HubCredentials? _credentials; @@ -174,16 +181,31 @@ public async Task ReloadAsync() /// Thrown if the form has not been rendered. Call this method only after the OnAfterRender lifecycle event. public async Task SubmitAsync() { + if (_isSubmitting) + return; + if (_form.Context is null) throw new InvalidOperationException("Form is not yet rendered. Call SubmitAsync after OnAfterRender."); - if (LoginType == UAuthLoginType.Pkce) + _isSubmitting = true; + + try { - await SubmitPkceAsync(); + await OnSubmittingChanged.InvokeAsync(true); + + if (LoginType == UAuthLoginType.Pkce) + { + await SubmitPkceAsync(); + } + else + { + await SubmitPasswordAsync(); + } } - else + finally { - await SubmitPasswordAsync(); + _isSubmitting = false; + await OnSubmittingChanged.InvokeAsync(false); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs index 31d28d09..57c31373 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs @@ -16,4 +16,15 @@ public ValueTask SetDeviceIdAsync(string deviceId) { return _js.InvokeVoidAsync("uauth.setDeviceId", deviceId); } + + public ValueTask ConfigureAntiforgeryAsync(string tokenUrl, string? trustedOrigin = null) + { + return _js.InvokeVoidAsync( + "uauth.antiforgery.configure", + new + { + tokenUrl, + trustedOrigin + }); + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs index 2c0ad536..96469232 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs @@ -1,5 +1,6 @@ ο»Ώusing CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Abstractions; @@ -19,6 +20,7 @@ internal sealed class SessionCoordinator : ISessionCoordinator private readonly IClock _clock; private CancellationTokenSource? _cts; + private DateTimeOffset _refreshNotBefore; public event Action? ReauthRequired; @@ -53,8 +55,11 @@ private async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { - _diagnostics.SetNextAutomaticRefresh(_clock.UtcNow.Add(interval)); - await Task.Delay(interval, ct); + var delay = _refreshNotBefore - _clock.UtcNow; + if (delay < interval) delay = interval; + _diagnostics.SetNextAutomaticRefresh(UAuthRateLimit.NotBefore(_clock.UtcNow, delay)); + // Chunk very long guidance to stay within native timer bounds; TickAsync still checks the deadline. + await Task.Delay(delay > TimeSpan.FromDays(1) ? TimeSpan.FromDays(1) : delay, ct); _diagnostics.SetNextAutomaticRefresh(null); await TickAsync(); @@ -86,10 +91,17 @@ public async ValueTask DisposeAsync() internal async Task TickAsync() { + if (_clock.UtcNow < _refreshNotBefore) return; _diagnostics.MarkAutomaticRefresh(); var result = await _client.Flows.RefreshAsync(true); + if (result.Status == 429) + { + _refreshNotBefore = UAuthRateLimit.NotBefore(_clock.UtcNow, result.RetryAfter); + return; + } + if (result.Outcome != RefreshOutcome.ReauthRequired) return; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index f35488ff..f1802b21 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using Microsoft.JSInterop; using System.Net; +using System.Text.Json; // TODO: Add fluent helper API like RequiredOk namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; @@ -66,6 +67,31 @@ public async Task SendFormAsync(string endpoint, IDictiona return result; } + public async Task SendFormAsync(string endpoint, IDictionary? form, bool navigateOnSuccess, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + await _bootstrapper.EnsureStartedAsync(); + + var result = await _js.InvokeAsync("uauth.post", ct, + new + { + url = endpoint, + mode = navigateOnSuccess ? "logout" : "logout-observe", + data = form, + clientProfile = _options.ClientProfile.ToString(), + trustedOrigin = ResolveTrustedOrigin() + }); + + if (result is null) + throw new UAuthProtocolException("Invalid logout transport response."); + + if (result.Status == 0) + throw new UAuthTransportException("Could not confirm server logout."); + + return result; + } + public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -95,7 +121,7 @@ public async Task TryAndCommitAsync(string tryEndpoint, await _bootstrapper.EnsureStartedAsync(); - var response = await _js.InvokeAsync( + var response = await _js.InvokeAsync( "uauth.tryAndCommit", ct, new @@ -107,20 +133,30 @@ public async Task TryAndCommitAsync(string tryEndpoint, trustedOrigin = ResolveTrustedOrigin() }); - if (response is null) + if (response.ValueKind != JsonValueKind.Object) throw new UAuthProtocolException("Invalid tryAndCommit response."); - return response; + if (response.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Number && status.TryGetInt32(out var code) && code == 429) + throw UAuthRateLimit.Exception(response.TryGetProperty("retryAfter", out var retry) && retry.ValueKind == JsonValueKind.String ? retry.GetString() : null); + try + { + return response.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new UAuthProtocolException("Invalid tryAndCommit response."); + } + catch (JsonException ex) { throw new UAuthProtocolException("Invalid tryAndCommit response.", ex); } } + private string? ResolveTrustedOrigin() { var basePath = _options.Endpoints.BasePath; - if (Uri.TryCreate(basePath, UriKind.Absolute, out var uri)) - return uri.GetLeftPart(UriPartial.Authority); + if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) && + (hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps)) + { + return hubUri.GetLeftPart(UriPartial.Authority); + } - // Relative BasePath: JS automatically uses the current origin. return null; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 9cae6ca0..237bb830 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -34,7 +34,10 @@ window.uauth.storage = { } }; -window.uauth.submitForm = function (form) { +window.uauth.submitForm = async function ( + form, + trustedOrigin = null) { + if (!form) return; @@ -42,15 +45,31 @@ window.uauth.submitForm = function (form) { throw new Error("UAuth deviceId is not initialized."); } - let udid = form.querySelector("input[name='__uauth_device']"); + const validatedUrl = window.uauth.validateAuthUrl( + form.action, + trustedOrigin + ); + + form.action = validatedUrl; + + let udid = form.querySelector( + "input[name='__uauth_device']" + ); + if (!udid) { udid = document.createElement("input"); udid.type = "hidden"; udid.name = "__uauth_device"; form.appendChild(udid); } + udid.value = window.uauth.deviceId; + await window.uauth.antiforgery.appendToForm( + form, + trustedOrigin + ); + form.submit(); }; @@ -91,6 +110,9 @@ window.uauth.tryAndCommit = async function (options) { } const status = tryResponse.status; + if (status === 429) { + return { isSuccess: false, status: 429, errorCode: "rate_limited", retryAfter: tryResponse.retryAfter ?? null }; + } const isHttpSuccess = status >= 200 && status < 300; const isClientError = status >= 400 && status < 500; @@ -155,7 +177,13 @@ window.uauth.tryAndCommit = async function (options) { form.appendChild(udid); document.body.appendChild(form); - form.submit(); + try { + await window.uauth.submitForm(form, trustedOrigin); + } + catch (error) { + form.remove(); + throw error; + } } return normalized; @@ -203,10 +231,20 @@ window.uauth.post = async function (options) { } document.body.appendChild(form); - form.submit(); + try { + await window.uauth.submitForm(form, trustedOrigin); + } + catch (error) { + form.remove(); + throw error; + } return null; } + const isLogout = + mode === "logout" || + mode === "logout-observe"; + return window.uauth.request({ url: validatedUrl, method: "POST", @@ -214,7 +252,13 @@ window.uauth.post = async function (options) { clientProfile, contentType: "form", credentials: "include", - allowCrossOriginCredentials: true + allowCrossOriginCredentials: true, + antiforgery: true, + trustedOrigin, + navigateOnSuccess: mode === "logout", + headers: isLogout + ? { "X-UAuth-Logout-Mode": "observe" } + : {} }); }; @@ -231,12 +275,16 @@ window.uauth.postJson = function (options) { clientProfile: options.clientProfile, contentType: "json", credentials: "include", - allowCrossOriginCredentials: true + allowCrossOriginCredentials: true, + antiforgery: true, + trustedOrigin: options.trustedOrigin }); }; window.uauth.request = async function (options) { const { + followRedirects = false, + navigateOnSuccess = false, url, method = "GET", payload = null, @@ -308,13 +356,27 @@ window.uauth.request = async function (options) { } } + if (requestMethod !== "GET" && options.antiforgery === true) { + const tokenUrl = window.uauth.validateAuthUrl( + window.uauth.antiforgery.tokenUrl, + options.trustedOrigin + ); + + if (new URL(tokenUrl).origin !== requestUrl.origin) { + throw new Error("UAuth antiforgery token origin differs from request origin."); + } + + const token = await window.uauth.antiforgery.getToken(options.trustedOrigin); + headers[token.headerName] = token.requestToken; + } + let response; try { response = await fetch(requestUrl.href, { method: requestMethod, credentials, - redirect: "error", + redirect: followRedirects ? "follow" : "error", headers, body }); @@ -347,7 +409,24 @@ window.uauth.request = async function (options) { } } + // Only the observed logout transport follows a successful server redirect. + // A rejected POST never navigates, clears storage, or causes a second POST. + if (navigateOnSuccess && response.ok) { + const redirectUrl = responseBody?.redirectUrl; + + if (typeof redirectUrl === "string" && redirectUrl.length > 0) { + const target = new URL(redirectUrl, window.location.origin); + + if (!["http:", "https:"].includes(target.protocol)) { + throw new Error("Invalid logout redirect protocol."); + } + + window.location.assign(target.href); + } + } + return { + retryAfter: response.headers.get("Retry-After"), ok: response.ok, status: response.status, refreshOutcome: response.headers.get("X-UAuth-Refresh"), @@ -490,6 +569,7 @@ window.uauth.resourceRequest = async function (options) { } return { + retryAfter: response.headers.get("Retry-After"), ok: response.ok, status: response.status, body: responseBody, @@ -566,6 +646,72 @@ window.uauth.validateAuthUrl = function (url, trustedOrigin = null) { return target.href; }; +window.uauth.antiforgery = { + tokenUrl: null, + + configure: function (options) { + if (!options?.tokenUrl) { + throw new Error("UAuth antiforgery token URL is required."); + } + + this.tokenUrl = window.uauth.validateAuthUrl( + options.tokenUrl, + options.trustedOrigin + ); + }, + + getToken: async function (trustedOrigin) { + if (!this.tokenUrl) { + throw new Error("UAuth antiforgery is not configured."); + } + + const url = window.uauth.validateAuthUrl( + this.tokenUrl, + trustedOrigin + ); + + const response = await fetch(url, { + method: "GET", + credentials: "include", + cache: "no-store", + redirect: "error", + headers: { + "X-UDID": window.uauth.deviceId + } + }); + + if (!response.ok) { + throw new Error(`Unable to obtain UAuth antiforgery token: HTTP ${response.status}.`); + } + + const token = await response.json(); + + if (!token?.requestToken || + !token?.headerName || + !token?.formFieldName) { + throw new Error("Invalid UAuth antiforgery response."); + } + + return token; + }, + + appendToForm: async function (form, trustedOrigin) { + const token = await this.getToken(trustedOrigin); + + let input = Array.from(form.elements) + .find(element => element.name === token.formFieldName); + + if (!input) { + input = document.createElement("input"); + input.type = "hidden"; + input.name = token.formFieldName; + form.appendChild(input); + } + + input.value = token.requestToken; + } +}; + window.uauth.setDeviceId = function (value) { window.uauth.deviceId = value; }; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index c0c82b59..5c205de1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:f,clientProfile:o,trustedOrigin:e}=n,a=window.uauth.validateAuthUrl(c,e),v=window.uauth.validateAuthUrl(l,e),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:f,clientProfile:o,trustedOrigin:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:u,mode:f,data:t,clientProfile:i,trustedOrigin:e}=n,r=window.uauth.validateAuthUrl(u,e);if(f==="navigate"){const n=document.createElement("form");n.method="POST";n.action=r;const u=document.createElement("input");u.type="hidden";u.name="__uauth_client_profile";u.value=i??"";n.appendChild(u);const f=document.createElement("input");if(f.type="hidden",f.name="__uauth_device",f.value=window.uauth.deviceId,n.appendChild(f),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}return window.uauth.request({url:r,method:"POST",payload:t,clientProfile:i,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.request=async function(n){const{url:h,method:a="GET",payload:i=null,clientProfile:v="",contentType:c="json",credentials:f="same-origin",headers:y={}}=n;if(!h)throw new Error("Request URL is required.");const e=new URL(h,document.baseURI);if(!["http:","https:"].includes(e.protocol))throw new Error("Unsupported request URL protocol.");const r=a.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(r))throw new Error(`Unsupported HTTP method: ${r}`);if(!["same-origin","include","omit"].includes(f))throw new Error("Invalid credentials policy.");if(f==="include"&&e.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={...y,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":v,"X-Requested-With":"UAuth"};let u=null;if(i!==null&&i!==undefined){if(r==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")o["Content-Type"]="application/json",u=JSON.stringify(i);else if(c==="form"){o["Content-Type"]="application/x-www-form-urlencoded";u=new URLSearchParams;for(const[n,t]of Object.entries(i))u.append(n,t??"")}else throw new Error("Unsupported content type.");}let t;try{t=await fetch(e.href,{method:r,credentials:f,redirect:"error",headers:o,body:u})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let s=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{s=JSON.parse(n)}catch{s=n}}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:s}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=async function(n,t=null){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const r=window.uauth.validateAuthUrl(n.action,t);n.action=r;let i=n.querySelector("input[name='__uauth_device']");i||(i=document.createElement("input"),i.type="hidden",i.name="__uauth_device",n.appendChild(i));i.value=window.uauth.deviceId;await window.uauth.antiforgery.appendToForm(n,t);n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:e,clientProfile:o,trustedOrigin:u}=n,a=window.uauth.validateAuthUrl(c,u),v=window.uauth.validateAuthUrl(l,u),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:e,clientProfile:o,trustedOrigin:u});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const f={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(f.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(e)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=e[t]??"";n.appendChild(i)}if(f.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=f.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);try{await window.uauth.submitForm(n,u)}catch(r){n.remove();throw r;}}return f};window.uauth.post=async function(n){const{url:e,mode:t,data:i,clientProfile:u,trustedOrigin:r}=n,f=window.uauth.validateAuthUrl(e,r);if(t==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const e=document.createElement("input");if(e.type="hidden",e.name="__uauth_device",e.value=window.uauth.deviceId,n.appendChild(e),i)for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t];n.appendChild(r)}document.body.appendChild(n);try{await window.uauth.submitForm(n,r)}catch(o){n.remove();throw o;}return null}const o=t==="logout"||t==="logout-observe";return window.uauth.request({url:f,method:"POST",payload:i,clientProfile:u,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:r,navigateOnSuccess:t==="logout",headers:o?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:n.trustedOrigin})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:r=null,clientProfile:p="",contentType:c="json",credentials:s="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const u=new URL(h,document.baseURI);if(!["http:","https:"].includes(u.protocol))throw new Error("Unsupported request URL protocol.");const i=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(i))throw new Error(`Unsupported HTTP method: ${i}`);if(!["same-origin","include","omit"].includes(s))throw new Error("Invalid credentials policy.");if(s==="include"&&u.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const f={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let e=null;if(r!==null&&r!==undefined){if(i==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")f["Content-Type"]="application/json",e=JSON.stringify(r);else if(c==="form"){f["Content-Type"]="application/x-www-form-urlencoded";e=new URLSearchParams;for(const[n,t]of Object.entries(r))e.append(n,t??"")}else throw new Error("Unsupported content type.");}if(i!=="GET"&&n.antiforgery===!0){const i=window.uauth.validateAuthUrl(window.uauth.antiforgery.tokenUrl,n.trustedOrigin);if(new URL(i).origin!==u.origin)throw new Error("UAuth antiforgery token origin differs from request origin.");const t=await window.uauth.antiforgery.getToken(n.trustedOrigin);f[t.headerName]=t.requestToken}let t;try{t=await fetch(u.href,{method:i,credentials:s,redirect:a?"follow":"error",headers:f,body:e})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let o=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{o=JSON.parse(n)}catch{o=n}}if(v&&t.ok){const n=o?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:o}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.antiforgery={tokenUrl:null,configure:function(n){if(!n?.tokenUrl)throw new Error("UAuth antiforgery token URL is required.");this.tokenUrl=window.uauth.validateAuthUrl(n.tokenUrl,n.trustedOrigin)},getToken:async function(n){if(!this.tokenUrl)throw new Error("UAuth antiforgery is not configured.");const r=window.uauth.validateAuthUrl(this.tokenUrl,n),i=await fetch(r,{method:"GET",credentials:"include",cache:"no-store",redirect:"error",headers:{"X-UDID":window.uauth.deviceId}});if(!i.ok)throw new Error(`Unable to obtain UAuth antiforgery token: HTTP ${i.status}.`);const t=await i.json();if(!t?.requestToken||!t?.headerName||!t?.formFieldName)throw new Error("Invalid UAuth antiforgery response.");return t},appendToForm:async function(n,t){const r=await this.getToken(t);let i=Array.from(n.elements).find(n=>n.name===r.formFieldName);i||(i=document.createElement("input"),i.type="hidden",i.name=r.formFieldName,n.appendChild(i));i.value=r.requestToken}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj index cdd30716..bc6e3e8f 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs index df208996..1b8fd30b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -1,4 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using System.Net; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -7,6 +10,7 @@ namespace CodeBeam.UltimateAuth.Client.Authentication; internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable { private Task? _ensureTask; + private DateTimeOffset _validationNotBefore; private readonly object _ensureLock = new(); private readonly IUAuthClient _client; @@ -26,6 +30,8 @@ public UAuthStateManager(IUAuthClient client, IUAuthClientEvents events, IClock public Task EnsureAsync(bool force = false, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (_clock.UtcNow < _validationNotBefore) return Task.CompletedTask; if (!force && State.IsAuthenticated && !State.IsStale) return Task.CompletedTask; @@ -59,6 +65,10 @@ private async Task EnsureInternalAsync(CancellationToken ct) State.ApplySnapshot(result.Snapshot, _clock.UtcNow); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { + _validationNotBefore = UAuthRateLimit.NotBefore(_clock.UtcNow, ex.RetryAfter); + } finally { lock (_ensureLock) diff --git a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index f7f329c1..7f13e4e3 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) CodeBeam.UltimateAuth.Client diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs index 82206fe2..dfca87be 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Client.Contracts; /// public sealed record RefreshResult { + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } /// /// Gets a value indicating whether the refresh operation completed successfully. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs index 73defa21..b6fbfb99 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs @@ -10,6 +10,10 @@ public sealed class UAuthResourceTransportResult [JsonPropertyName("ok")] public bool Ok { get; init; } + /// Raw Retry-After header, when exposed by the server/CORS policy. + [JsonPropertyName("retryAfter")] + public string? RetryAfter { get; init; } + [JsonPropertyName("status")] public int Status { get; init; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 0e87ba67..4381fd98 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -18,6 +18,10 @@ public sealed class UAuthTransportResult [JsonPropertyName("ok")] public bool Ok { get; init; } + /// Raw Retry-After header, when exposed by the server/CORS policy. + [JsonPropertyName("retryAfter")] + public string? RetryAfter { get; init; } + /// /// Gets the HTTP status code returned by the request. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs index b41c0f6a..2c123769 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Client.Errors; public sealed class UAuthTransportException : UAuthClientException { public HttpStatusCode? StatusCode { get; } + public TimeSpan? RetryAfter { get; init; } public UAuthTransportException(string message) : base("transport_error", message) { @@ -14,12 +15,12 @@ public UAuthTransportException(string message, Exception? inner) : base("transpo { } - public UAuthTransportException(string message, HttpStatusCode statusCode) : base("transport_error", message) + public UAuthTransportException(string message, HttpStatusCode statusCode) : base(statusCode == HttpStatusCode.TooManyRequests ? "rate_limited" : "transport_error", message) { StatusCode = statusCode; } - public UAuthTransportException(string message, HttpStatusCode statusCode, Exception? inner) : base("transport_error", message, inner) + public UAuthTransportException(string message, HttpStatusCode statusCode, Exception? inner) : base(statusCode == HttpStatusCode.TooManyRequests ? "rate_limited" : "transport_error", message, inner) { StatusCode = statusCode; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs index 54798cf1..5cbe9fc3 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs @@ -9,4 +9,6 @@ public interface IBrowserUAuthBridge /// Sets the device ID in the browser's local storage or cookies. /// ValueTask SetDeviceIdAsync(string deviceId); + + ValueTask ConfigureAntiforgeryAsync(string tokenUrl, string? trustedOrigin = null); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index b1c7d47b..521adeae 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -28,6 +28,10 @@ public interface IUAuthRequestClient Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + /// Observes logout before following successful browser redirects. Existing adapters may delegate to their form transport. + Task SendFormAsync(string endpoint, IDictionary? form, bool navigateOnSuccess, CancellationToken ct = default) + => SendFormAsync(endpoint, form, ct); + /// /// Sends a JSON payload to the specified endpoint and returns the result of the operation. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs index c70a0375..6cee1b18 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs @@ -1,6 +1,12 @@ -ο»Ώnamespace CodeBeam.UltimateAuth.Client.Infrastructure; +ο»Ώusing CodeBeam.UltimateAuth.Client.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; // TODO: Add device id auto creation for MVC, this is only for blazor. +// TODO: Support dynamic tenant changes during the application lifetime. +// Antiforgery configuration is currently initialized only once. +// Reconfigure the token URL when the active tenant changes. internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper { private readonly SemaphoreSlim _gate = new(1, 1); @@ -8,13 +14,15 @@ internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper private readonly IDeviceIdProvider _deviceIdProvider; private readonly IBrowserUAuthBridge _browser; + private readonly UAuthClientOptions _options; public bool IsStarted => _started; - public UAuthClientBootstrapper(IDeviceIdProvider deviceIdProvider, IBrowserUAuthBridge browser) + public UAuthClientBootstrapper(IDeviceIdProvider deviceIdProvider, IBrowserUAuthBridge browser, IOptions options) { _deviceIdProvider = deviceIdProvider; _browser = browser; + _options = options.Value; } public async Task EnsureStartedAsync(CancellationToken ct = default) @@ -23,14 +31,30 @@ public async Task EnsureStartedAsync(CancellationToken ct = default) return; await _gate.WaitAsync(ct); + try { if (_started) return; var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + + var basePath = _options.Endpoints.BasePath; + + var tokenUrl = UAuthUrlBuilder.Build(basePath, _options.Endpoints.AntiforgeryToken, _options.MultiTenant); + + string? trustedOrigin = null; + + if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) && + (hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps)) + { + trustedOrigin = hubUri.GetLeftPart(UriPartial.Authority); + } + await _browser.SetDeviceIdAsync(deviceId.Value); + await _browser.ConfigureAntiforgeryAsync(tokenUrl, trustedOrigin); + _started = true; } finally diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs new file mode 100644 index 00000000..13d8a380 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthPasswordClientValidator.cs @@ -0,0 +1,52 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client; +public static class UAuthPasswordClientValidator +{ + public static IReadOnlyList Validate(string? password, UAuthPasswordPolicyInfo policy) + { + ArgumentNullException.ThrowIfNull(policy); + + var errors = new List(); + + if (string.IsNullOrEmpty(password)) + { + errors.Add(PasswordPolicyErrorCodes.Required); + return errors; + } + + if (password.Length > 256) + { + errors.Add(PasswordPolicyErrorCodes.InputTooLong); + return errors; + } + + if (password.Length < policy.MinLength) + errors.Add(PasswordPolicyErrorCodes.TooShort); + + if (password.Length > policy.MaxLength) + errors.Add(PasswordPolicyErrorCodes.TooLong); + + if (policy.RequireLetter && + !password.Any(char.IsLetter)) + errors.Add(PasswordPolicyErrorCodes.LetterRequired); + + if (policy.RequireDigit && + !password.Any(char.IsDigit)) + errors.Add(PasswordPolicyErrorCodes.DigitRequired); + + if (policy.RequireUppercase && + !password.Any(char.IsUpper)) + errors.Add(PasswordPolicyErrorCodes.UppercaseRequired); + + if (policy.RequireLowercase && + !password.Any(char.IsLower)) + errors.Add(PasswordPolicyErrorCodes.LowercaseRequired); + + if (policy.RequireSpecialCharacter && + !password.Any(c => !char.IsLetterOrDigit(c))) + errors.Add(PasswordPolicyErrorCodes.SpecialCharacterRequired); + + return errors; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs new file mode 100644 index 00000000..32923b4e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using System.Net; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class UAuthRateLimit +{ + internal static TimeSpan? ParseRetryAfter(string? header, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(header)) return null; + header = header.Trim(); + if (long.TryParse(header, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) + return seconds <= TimeSpan.MaxValue.Ticks / TimeSpan.TicksPerSecond ? TimeSpan.FromTicks(seconds * TimeSpan.TicksPerSecond) : null; + if (DateTimeOffset.TryParseExact(header, "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + return date > now ? date - now : TimeSpan.Zero; + return null; + } + + internal static DateTimeOffset NotBefore(DateTimeOffset now, TimeSpan? retryAfter) + { + var delay = retryAfter ?? TimeSpan.FromSeconds(30); + if (delay < TimeSpan.FromSeconds(1)) delay = TimeSpan.FromSeconds(1); + return delay.Ticks > DateTimeOffset.MaxValue.Ticks - now.UtcTicks ? DateTimeOffset.MaxValue : now.Add(delay); + } + + internal static UAuthTransportException Exception(string? header) => new("Temporarily rate limited.", HttpStatusCode.TooManyRequests) + { RetryAfter = ParseRetryAfter(header, DateTimeOffset.UtcNow) }; + + internal static UAuthProblem Problem(string? header) => new() + { Status = 429, Title = "Too many requests", Detail = "rate_limited", RetryAfter = ParseRetryAfter(header, DateTimeOffset.UtcNow) }; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 17bc8eef..0ca40000 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -94,6 +94,7 @@ private static void EnsureTransport(UAuthTransportResult raw) private static UAuthProblem? CreateProblem(UAuthTransportResult raw) { + if (raw.Status == 429) return UAuthRateLimit.Problem(raw.RetryAfter); var parsed = TryDeserializeProblem(raw); if (parsed is null) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 41ff2612..4b6de185 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -19,17 +19,45 @@ public static class UAuthUrlBuilder public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { var baseAuthority = authority.TrimEnd('/'); + var endpoint = relativePath.TrimStart('/'); - if (tenant.Enabled && tenant.Transport == TenantTransport.Route) + if (!tenant.Enabled || tenant.Transport != TenantTransport.Route) + return baseAuthority + "/" + endpoint; + + if (string.IsNullOrWhiteSpace(tenant.Tenant)) + throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided."); + + var tenantId = tenant.Tenant.Trim('/'); + + if (tenantId.Length == 0 || + tenantId is "." or ".." || + tenantId.Contains('/') || + tenantId.Contains('\\') || + tenantId.Contains('?') || + tenantId.Contains('#')) { - if (string.IsNullOrWhiteSpace(tenant.Tenant)) + throw new ArgumentException("Invalid tenant route segment.", nameof(tenant)); + } + + var segment = Uri.EscapeDataString(tenantId); + + if (baseAuthority.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || baseAuthority.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.TryCreate(baseAuthority, UriKind.Absolute, out var uri)) { - throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided."); - } + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + throw new ArgumentException("Authority must use HTTP or HTTPS.", nameof(authority)); + + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + throw new ArgumentException("Authority cannot contain a query or fragment.", nameof(authority)); - baseAuthority = "/" + tenant.Tenant.Trim('/') + baseAuthority; + var origin = uri.GetLeftPart(UriPartial.Authority); + var path = uri.AbsolutePath.TrimEnd('/'); + + return origin + "/" + segment + path + "/" + endpoint; + } } - return baseAuthority + "/" + relativePath.TrimStart('/'); + return "/" + segment + "/" + baseAuthority.TrimStart('/') + "/" + endpoint; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs index 6a8ab4e2..00545954 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -55,8 +55,19 @@ public sealed class UAuthClientEndpointOptions /// public string PkceComplete { get; set; } = "/pkce/complete"; + /// + /// Path for obtaining an antiforgery request token. + /// + public string AntiforgeryToken { get; set; } = "/antiforgery/token"; + /// /// Path for the UAuthHub login endpoint (e.g. /uauthhub/entry) /// public string HubLoginPath { get; set; } = "/uauthhub/entry"; + + + /// + /// Endpoint used to retrieve the effective public password policy. + /// + public string PasswordPolicy { get; set; } = "/password-policy"; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs index ec28848d..4ae92705 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -112,4 +112,16 @@ public interface ICredentialClient /// This removes softly or permanently the credential from the system. /// Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request); + + + /// + /// Retrieves the effective public password policy for the + /// current tenant and client profile. + /// + /// + /// Available without authentication when the server + /// exposes the credentials endpoints. + /// The response contains no password history or user data. + /// + Task> GetPasswordPolicyAsync(CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs index 2387d852..f07be3c2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -58,10 +58,10 @@ public interface IFlowClient /// /// Logs out the current user. /// - /// - /// This clears the current session and may trigger redirects. - /// - Task LogoutAsync(); + /// + /// Whether to navigate to the server-provided logout destination after a successful logout. Defaults to true. + /// + Task LogoutAsync(bool navigate = true); /// /// Refreshes the current session or tokens. diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 264b7f44..7ee1071a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -113,4 +113,11 @@ public async Task DeleteUserAsync(UserKey userKey, DeleteCredential var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"), request); return UAuthResultMapper.From(raw); } + + + public async Task> GetPasswordPolicyAsync(CancellationToken ct = default) + { + var raw = await _request.SendJsonAsync(Url(_options.Endpoints.PasswordPolicy), payload: null, ct: ct); + return UAuthResultMapper.FromJson(raw); + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 41ede98f..ac2ba235 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -27,6 +27,8 @@ internal class UAuthFlowClient : IFlowClient private readonly IReturnUrlProvider _returnUrlProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; + private DateTimeOffset _refreshNotBefore; + private RefreshResult? _refreshThrottle; public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) { @@ -65,6 +67,7 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi { var raw = await _post.SendJsonAsync(tryUrl, payload); + if (raw.Status == 429) return new TryLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) }; EnsureValidHttpStatus(raw); if (raw.Status is >= 400 and < 500) @@ -102,7 +105,10 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi case UAuthSubmitMode.TryAndCommit: default: { - var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + TryLoginResult result; + try { result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { return new TryLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = ex.RetryAfter }; } if (result is null) throw new UAuthProtocolException("Invalid try-login result."); @@ -112,12 +118,18 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi } } - public async Task LogoutAsync() + public async Task LogoutAsync(bool navigate = true) { var url = Url(_options.Endpoints.Logout); - await _post.NavigateAsync(url); - } + var raw = await _post.SendFormAsync(url, null, navigateOnSuccess: navigate); + + EnsureValidHttpStatus(raw); + if (raw.Status is < 200 or >= 300) + throw new UAuthTransportException("Server logout did not succeed.", (HttpStatusCode)raw.Status); + + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } public async Task RefreshAsync(bool isAuto = false) { @@ -126,7 +138,24 @@ public async Task RefreshAsync(bool isAuto = false) _diagnostics.MarkManualRefresh(); } + var now = DateTimeOffset.UtcNow; + if (_refreshThrottle is not null && now < _refreshNotBefore) + return _refreshThrottle with { RetryAfter = _refreshNotBefore - now }; var raw = await _post.SendFormAsync(Url(_options.Endpoints.Refresh)); + if (raw.Status == 429) + { + _refreshThrottle = new RefreshResult + { + Status = 429, + IsSuccess = false, + Outcome = RefreshOutcome.None, + ErrorCode = "rate_limited", + RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) + }; + _refreshNotBefore = UAuthRateLimit.NotBefore(DateTimeOffset.UtcNow, _refreshThrottle.RetryAfter); + return _refreshThrottle; + } + _refreshThrottle = null; // 401 is a normal authentication outcome for refresh. if (raw.Status == 401) @@ -197,6 +226,7 @@ public async Task ValidateAsync() var url = Url(_options.Endpoints.Validate); var raw = await _post.SendFormAsync(url); + if (raw.Status == 429) throw UAuthRateLimit.Exception(raw.RetryAfter); if (raw.Status == 0) throw new UAuthTransportException("Network error during validation."); @@ -335,6 +365,7 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ case UAuthSubmitMode.TryOnly: { var raw = await _post.SendJsonAsync(tryUrl, request); + if (raw.Status == 429) return new TryPkceLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) }; EnsureValidHttpStatus(raw); @@ -367,7 +398,10 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ case UAuthSubmitMode.TryAndCommit: default: { - var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + TryPkceLoginResult result; + try { result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { return new TryPkceLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = ex.RetryAfter }; } if (result is null) throw new UAuthProtocolException("Invalid PKCE try result."); @@ -530,6 +564,7 @@ private static void EnsureValidHttpStatus(UAuthTransportResult raw) if (raw is null) throw new UAuthProtocolException("Transport client returned a null response."); + if (raw.Status == 429) throw UAuthRateLimit.Exception(raw.RetryAfter); if (raw.Status == 0) throw new UAuthTransportException("Network error."); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs index 5e9eb4c1..a9998c63 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs @@ -34,9 +34,9 @@ public async Task DeleteAsync(string endpoint, CancellationToken ct return new UAuthResult { - IsSuccess = response.Ok, + IsSuccess = (response.Ok && response.Status is >= 200 and < 300), Status = response.Status, - Problem = response.Ok ? null : ReadProblem(response) + Problem = (response.Ok && response.Status is >= 200 and < 300) ? null : ReadProblem(response) }; } @@ -46,10 +46,10 @@ private async Task> SendAsync(string method, string endpoint, return new UAuthResult { - IsSuccess = response.Ok, + IsSuccess = (response.Ok && response.Status is >= 200 and < 300), Status = response.Status, - Value = response.Ok ? ReadValue(response) : default, - Problem = response.Ok ? null : ReadProblem(response) + Value = (response.Ok && response.Status is >= 200 and < 300) ? ReadValue(response) : default, + Problem = (response.Ok && response.Status is >= 200 and < 300) ? null : ReadProblem(response) }; } @@ -63,6 +63,7 @@ private async Task> SendAsync(string method, string endpoint, private static UAuthProblem? ReadProblem(UAuthResourceTransportResult response) { + if (response.Status == 429) return UAuthRateLimit.Problem(response.RetryAfter); if (string.IsNullOrWhiteSpace(response.Body)) return null; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj index 376e6b7a..1186eb80 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Credentials.Contracts diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs new file mode 100644 index 00000000..d1ae35bf --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyErrorCodes.cs @@ -0,0 +1,20 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class PasswordPolicyErrorCodes +{ + public const string Required = "password_required"; + public const string TooShort = "password_too_short"; + public const string TooLong = "password_too_long"; + + public const string LetterRequired = "password_letter_required"; + public const string DigitRequired = "password_digit_required"; + public const string UppercaseRequired = "password_uppercase_required"; + public const string LowercaseRequired = "password_lowercase_required"; + public const string SpecialCharacterRequired = "password_special_character_required"; + + public const string CommonPassword = "password_too_common"; + public const string SimilarToUser = "password_similar_to_user"; + + public const string ReusedPassword = "password_reused"; + public const string InputTooLong = "password_input_too_long"; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs new file mode 100644 index 00000000..dbf5457f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLevel.cs @@ -0,0 +1,12 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +/// +/// Defines the scope of password policy enforcement. +/// +public enum PasswordPolicyLevel +{ + Disabled = 0, + Basic = 10, + Standard = 20, + Strict = 30 +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs new file mode 100644 index 00000000..dee0f9a5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyLimits.cs @@ -0,0 +1,11 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class PasswordPolicyLimits +{ + public const int MinimumLength = 6; + public const int AbsoluteMaxLength = 256; + + public const int DefaultMaxLength = 128; + + public const int MaxHistoryCount = 10; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs new file mode 100644 index 00000000..3b8b1efa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/PasswordPolicyViolation.cs @@ -0,0 +1,3 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record PasswordPolicyViolation(string Code, string Message); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs new file mode 100644 index 00000000..6b2284de --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/UAuthPasswordPolicyInfo.cs @@ -0,0 +1,19 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record UAuthPasswordPolicyInfo +{ + public required PasswordPolicyLevel Level { get; init; } + + public required int MinLength { get; init; } + public required int MaxLength { get; init; } + + public required bool RequireLetter { get; init; } + public required bool RequireDigit { get; init; } + public required bool RequireUppercase { get; init; } + public required bool RequireLowercase { get; init; } + public required bool RequireSpecialCharacter { get; init; } + + public required bool CheckCommonPasswords { get; init; } + public required bool CheckUserSimilarity { get; init; } + public required bool CheckPasswordHistory { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs new file mode 100644 index 00000000..9473fdc4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/PasswordPolicyResult.cs @@ -0,0 +1,18 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class PasswordPolicyResult +{ + public bool Succeeded => Violations.Count == 0; + + public IReadOnlyList Violations { get; } + + public PasswordPolicyResult(IEnumerable violations) + { + ArgumentNullException.ThrowIfNull(violations); + Violations = Array.AsReadOnly(violations.ToArray()); + } + + public static PasswordPolicyResult Success() => new(Array.Empty()); + + public static PasswordPolicyResult Failure(params PasswordPolicyViolation[] violations) => new(violations); +} 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 978ce78c..92f06217 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs index fb5a975d..bf897e3d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; public sealed class UAuthCredentialDbContext : DbContext { public DbSet PasswordCredentials => Set(); + public DbSet PasswordHistory => Set(); public UAuthCredentialDbContext(DbContextOptions options) : base(options) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs index 5b1b3ab5..b0da5a05 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs @@ -11,6 +11,7 @@ public static class UAuthCredentialsModelBuilder public static void Configure(ModelBuilder b) { ConfigurePasswordCredentials(b); + ConfigurePasswordHistory(b); } private static void ConfigurePasswordCredentials(ModelBuilder b) @@ -61,4 +62,40 @@ private static void ConfigurePasswordCredentials(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); }); } + + private static void ConfigurePasswordHistory(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_PasswordHistory"); + + e.HasKey(x => x.Id); + + 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.CredentialId) + .IsRequired(); + + e.Property(x => x.SecretHash) + .HasConversion(v => v.ToString(), v => PasswordHash.Parse(v, null)) + .HasMaxLength(512) + .IsRequired(); + + e.Property(x => x.ReplacedAt) + .HasUtcDateTimeOffsetConverter() + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.CredentialId, x.ReplacedAt, x.Id }); + }); + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 2925ccb8..93200a20 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(); services.AddScoped>(); + services.AddScoped>(); return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs new file mode 100644 index 00000000..4f1b5c35 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordHistoryProjection.cs @@ -0,0 +1,19 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class PasswordHistoryProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } + + public Guid CredentialId { get; set; } + + public PasswordHash SecretHash { get; set; } + + public DateTimeOffset ReplacedAt { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs new file mode 100644 index 00000000..40d287d8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStore.cs @@ -0,0 +1,113 @@ +ο»Ώ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 EfCorePasswordHistoryStore(TDbContext db, TenantKey tenant) : IPasswordHistoryStore where TDbContext : DbContext +{ + private DbSet DbSet => db.Set(); + + public async Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + var entries = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == tenant && x.CredentialId == credentialKey.Id) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(count) + .ToListAsync(ct); + + return entries.Select(x => new PasswordHistoryEntry + { + Id = x.Id, + Tenant = x.Tenant, + UserKey = x.UserKey, + CredentialKey = new CredentialKey(x.Tenant, x.CredentialId), + SecretHash = x.SecretHash, + ReplacedAt = x.ReplacedAt + }).ToArray(); + } + + public async Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(entry); + ValidateKey(entry.CredentialKey); + + if (entry.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + + if (entry.Id == Guid.Empty) + throw new ArgumentException("History entry ID is required."); + + DbSet.Add(new PasswordHistoryProjection + { + Id = entry.Id, + Tenant = entry.Tenant, + UserKey = entry.UserKey, + CredentialId = entry.CredentialKey.Id, + SecretHash = entry.SecretHash, + ReplacedAt = entry.ReplacedAt + }); + + await db.SaveChangesAsync(ct); + } + + public async Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + if (keepLast < 0) + throw new ArgumentOutOfRangeException(nameof(keepLast)); + + var keepIds = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == tenant && x.CredentialId == credentialKey.Id) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(keepLast) + .Select(x => x.Id) + .ToListAsync(ct); + + await DbSet + .Where(x => + x.Tenant == tenant && + x.CredentialId == credentialKey.Id && + !keepIds.Contains(x.Id)) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default) + { + ValidateKey(credentialKey); + + await DbSet + .Where(x => + x.Tenant == tenant && + x.CredentialId == credentialKey.Id) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default) + { + await DbSet + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + } + + private void ValidateKey(CredentialKey key) + { + if (key.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..1f8d2b32 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordHistoryStoreFactory.cs @@ -0,0 +1,13 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordHistoryStoreFactory(TDbContext db) : IPasswordHistoryStoreFactory where TDbContext : DbContext +{ + public IPasswordHistoryStore Create(TenantKey tenant) + { + return new EfCorePasswordHistoryStore(db, tenant); + } +} 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 7bc21c6a..0351770a 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Credentials.InMemory diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs similarity index 79% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs index 46acfd8f..613253e1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Credentials.Reference; +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,6 +12,7 @@ public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServic services.AddUltimateAuthInMemoryInfrastructure(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStore.cs similarity index 100% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStore.cs diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStoreFactory.cs similarity index 100% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordCredentialStoreFactory.cs diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs new file mode 100644 index 00000000..3b98017c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStore.cs @@ -0,0 +1,131 @@ +ο»Ώ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; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordHistoryStore(TenantKey tenant, InMemoryAtomicContextAccessor atomicContext) : IPasswordHistoryStore +{ + private readonly ConcurrentDictionary _entries = new(); + + public Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + IReadOnlyList result = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Take(count) + .ToArray(); + + return Task.FromResult(result); + } + + public Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(entry); + + ValidateKey(entry.CredentialKey); + + if (entry.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + + if (entry.Id == Guid.Empty) + throw new ArgumentException("History entry ID is required."); + + if (!_entries.TryAdd(entry.Id, entry)) + throw new UAuthConflictException("password_history_exists"); + + atomicContext.Current?.RegisterRollback(() => + { + if (!_entries.TryRemove(new KeyValuePair(entry.Id, entry))) + { + throw new InvalidOperationException("Password history rollback failed."); + } + }); + + return Task.CompletedTask; + } + + public Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + if (keepLast < 0) + throw new ArgumentOutOfRangeException(nameof(keepLast)); + + var remove = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .OrderByDescending(x => x.ReplacedAt) + .ThenByDescending(x => x.Id) + .Skip(keepLast) + .ToArray(); + + RemoveEntries(remove); + + return Task.CompletedTask; + } + + public Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateKey(credentialKey); + + var remove = _entries.Values + .Where(x => x.CredentialKey == credentialKey) + .ToArray(); + + RemoveEntries(remove); + + return Task.CompletedTask; + } + + public Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var entries = _entries.Values + .Where(x => x.UserKey == userKey) + .ToArray(); + + RemoveEntries(entries); + + return Task.CompletedTask; + } + + private void RemoveEntries(IReadOnlyCollection entries) + { + foreach (var entry in entries) + { + if (!_entries.TryRemove(new KeyValuePair(entry.Id, entry))) + { + throw new InvalidOperationException("Password history entry changed concurrently."); + } + + atomicContext.Current?.RegisterRollback(() => + { + if (!_entries.TryAdd(entry.Id, entry)) + { + throw new InvalidOperationException("Password history rollback failed."); + } + }); + } + } + + private void ValidateKey(CredentialKey key) + { + if (key.Tenant != tenant) + throw new UAuthConflictException("tenant_mismatch"); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..0c3181cd --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/Stores/InMemoryPasswordHistoryStoreFactory.cs @@ -0,0 +1,16 @@ +ο»Ώusing System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +public sealed class InMemoryPasswordHistoryStoreFactory(InMemoryAtomicContextAccessor atomicContext) : IPasswordHistoryStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IPasswordHistoryStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryPasswordHistoryStore(t, atomicContext)); + } +} 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 ec99e9ab..2656c92f 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Credentials.Reference diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs new file mode 100644 index 00000000..25107f45 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordHistoryEntry.cs @@ -0,0 +1,20 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed record PasswordHistoryEntry +{ + public required Guid Id { get; init; } + + public required TenantKey Tenant { get; init; } + public required UserKey UserKey { get; init; } + + public required CredentialKey CredentialKey { get; init; } + + public required PasswordHash SecretHash { get; init; } + + public required DateTimeOffset ReplacedAt { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index b7c8ae8a..7bc3cfb1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,10 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.AddScoped(); + 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 918ea9c4..25697d9c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -2,7 +2,11 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; @@ -12,16 +16,24 @@ internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegrati { private readonly IPasswordCredentialStoreFactory _credentialStoreFactory; private readonly IUAuthPasswordHasher _passwordHasher; + private readonly PasswordCredentialWriter _passwordWriter; + private readonly IPasswordHistoryStoreFactory _historyStores; + private readonly IPasswordPolicyContextProvider _passwordContextProvider; + private readonly IEffectiveServerOptionsProvider _effectiveServerOptionsProvider; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, PasswordCredentialWriter passwordWriter, IPasswordHistoryStoreFactory historyStores, IPasswordPolicyContextProvider passwordContextProvider, IEffectiveServerOptionsProvider effectiveServerOptionsProvider, IClock clock) { _credentialStoreFactory = credentialStoreFactory; _passwordHasher = passwordHasher; + _passwordWriter = passwordWriter; + _historyStores = historyStores; + _passwordContextProvider = passwordContextProvider; + _effectiveServerOptionsProvider = effectiveServerOptionsProvider; _clock = clock; } - public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct) + public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, UAuthClientProfile clientProfile, CancellationToken ct) { if (request is not CreateUserRequest r) return; @@ -29,24 +41,28 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r if (string.IsNullOrWhiteSpace(r.Password)) return; - var hash = _passwordHasher.Hash(r.Password); - - var credential = PasswordCredential.Create( - id: null, - tenant: tenant, - userKey: userKey, - secretHash: hash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { }, - _clock.UtcNow); - - var credentialStore = _credentialStoreFactory.Create(tenant); - await credentialStore.AddAsync(credential, ct); + var passwordContext = await _passwordContextProvider.CreateAsync(tenant, userKey, r.Password, ct); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(tenant, clientProfile); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); + await _passwordWriter.CreateAsync( + tenant, + userKey, + r.Password, + passwordContext, + passwordOptions, + _clock.UtcNow, + ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) { var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.DeleteByUserAsync(userKey, mode, _clock.UtcNow, ct); + + if (mode == DeleteMode.Hard) + { + await _historyStores.Create(tenant).DeleteByUserAsync(userKey, ct); + } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs new file mode 100644 index 00000000..7d6d6402 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordHistoryValidator.cs @@ -0,0 +1,8 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryValidator +{ + Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs new file mode 100644 index 00000000..2cfb3af7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/IPasswordValidationPipeline.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordValidationPipeline +{ + Task ValidateAsync(PasswordValidationRequest request, EffectivePasswordPolicyOptions options, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs new file mode 100644 index 00000000..cab106f7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/PasswordValidationRequest.cs @@ -0,0 +1,12 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed record PasswordValidationRequest +{ + public required PasswordPolicyContext Context { get; init; } + + // Null for first-time credential creation. + public CredentialKey? ExistingCredentialKey { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs new file mode 100644 index 00000000..ba4ef307 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordHistoryValidator.cs @@ -0,0 +1,45 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class UAuthPasswordHistoryValidator(IPasswordCredentialStoreFactory credentialStoreFactory, IPasswordHistoryStoreFactory historyStoreFactory, IUAuthPasswordHasher hasher) : IPasswordHistoryValidator +{ + public async Task ValidateAsync(CredentialKey credentialKey, string password, int rememberLast, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (rememberLast is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(rememberLast)); + + var credentialStore = credentialStoreFactory.Create(credentialKey.Tenant); + + var credential = await credentialStore.GetAsync(credentialKey, ct); + + if (credential is null) + throw new InvalidOperationException("Password credential was not found."); + + if (hasher.Verify(credential.SecretHash, password)) + return Reused(); + + if (rememberLast == 0) + return PasswordPolicyResult.Success(); + + var historyStore = historyStoreFactory.Create(credentialKey.Tenant); + var previous = await historyStore.GetRecentAsync(credentialKey, rememberLast, ct); + + foreach (var entry in previous) + { + ct.ThrowIfCancellationRequested(); + + if (hasher.Verify(entry.SecretHash, password)) + return Reused(); + } + + return PasswordPolicyResult.Success(); + } + + private static PasswordPolicyResult Reused() + => PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.ReusedPassword, "Password was used previously.")); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs new file mode 100644 index 00000000..7fb9fbfa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Policies/UAuthPasswordValidationPipeline.cs @@ -0,0 +1,40 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class UAuthPasswordValidationPipeline(IPasswordStrengthPolicy strengthPolicy, IPasswordHistoryValidator historyValidator) : IPasswordValidationPipeline +{ + public async Task ValidateAsync(PasswordValidationRequest request, EffectivePasswordPolicyOptions options, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Context); + + ct.ThrowIfCancellationRequested(); + + var password = request.Context.Password; + + if (password is null) + { + return PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.Required, "Password is required.")); + } + + // Mandatory guard, independent of custom strength policies. + if (password.Length > PasswordPolicyLimits.AbsoluteMaxLength) + { + return PasswordPolicyResult.Failure(new PasswordPolicyViolation(PasswordPolicyErrorCodes.InputTooLong, "Password exceeds the absolute length limit.")); + } + + var strength = await strengthPolicy.ValidateAsync(request.Context, options, ct); + var violations = new List(strength.Violations); + + if (request.ExistingCredentialKey is { } credentialKey) + { + var history = await historyValidator.ValidateAsync(credentialKey, password, options.RememberLast, ct); + violations.AddRange(history.Violations); + } + + return new PasswordPolicyResult(violations); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 7118a076..7a2ef4c0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -5,6 +5,8 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; @@ -28,6 +30,10 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ICredentialResetNotifier _resetNotifier; private readonly ICredentialSessionPolicy _credentialSessionPolicy; private readonly IUAuthAtomicExecutor _atomicExecutor; + private readonly PasswordCredentialWriter _passwordWriter; + private readonly IPasswordHistoryStoreFactory _historyStores; + private readonly IPasswordPolicyContextProvider _passwordContextProvider; + private readonly IEffectiveServerOptionsProvider _effectiveServerOptionsProvider; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -44,6 +50,10 @@ public CredentialManagementService( ICredentialResetNotifier resetNotifier, ICredentialSessionPolicy credentialSessionPolicy, IUAuthAtomicExecutor atomicExecutor, + PasswordCredentialWriter passwordWriter, + IPasswordHistoryStoreFactory historyStores, + IPasswordPolicyContextProvider passwordContextProvider, + IEffectiveServerOptionsProvider effectiveOptionsProvider, IOptions options, IClock clock) { @@ -59,6 +69,10 @@ public CredentialManagementService( _resetNotifier = resetNotifier; _credentialSessionPolicy = credentialSessionPolicy; _atomicExecutor = atomicExecutor; + _passwordWriter = passwordWriter; + _historyStores = historyStores; + _passwordContextProvider = passwordContextProvider; + _effectiveServerOptionsProvider = effectiveOptionsProvider; _options = options.Value; _clock = clock; } @@ -99,27 +113,28 @@ public async Task AddAsync(AccessContext context, AddCreden { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var subjectUser = context.GetTargetUserKey(); - var now = _clock.UtcNow; - - var hash = _hasher.Hash(request.Secret); + var cmd = new AccessCommand( + innerCt => _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var subjectUser = context.GetTargetUserKey(); + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.Secret, atomicCt); - var credential = PasswordCredential.Create( - id: null, - tenant: context.ResourceTenant, - userKey: subjectUser, - secretHash: hash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata(), - now: now); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); - var store = _credentialsFactory.Create(context.ResourceTenant); - await store.AddAsync(credential, innerCt); + var credential = await _passwordWriter.CreateAsync( + context.ResourceTenant, + subjectUser, + request.Secret, + passwordContext, + passwordOptions, + _clock.UtcNow, + atomicCt); - return CredentialAddResult.Success(credential.Id, credential.Type); - }); + return CredentialAddResult.Success(credential.Id, credential.Type); + }, + innerCt)); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -156,13 +171,10 @@ public async Task ChangeSecretAsync(AccessContext contex throw new UAuthConflictException("invalid_credentials"); } - if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - throw new UAuthValidationException("credential_secret_same"); - - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); - await store.SaveAsync(updated, oldVersion, atomicCt); + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, subjectUser, request.NewSecret, atomicCt); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); + await _passwordWriter.ChangeAsync(pwd, request.NewSecret, passwordContext, passwordOptions, now, atomicCt); await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction ? CredentialChangeReason.SelfChange @@ -304,21 +316,21 @@ public async Task CompleteResetAsync(AccessContext conte var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); if (context.IsAdminContext && resolution?.UserKey != context.GetTargetUserKey()) + { throw new UAuthValidationException("credential_target_mismatch"); + } if (resolution?.UserKey is not UserKey userKey) - { - // Enumeration protection return CredentialActionResult.Success(); - } - var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); if (state.IsResetExpired(now)) { - var version2 = state.SecurityVersion; var cleared = state.ClearReset(); - await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + + await _authenticationSecurityManager.UpdateAsync(cleared, state.SecurityVersion, innerCt); if (context.IsAnonymousContext) return CredentialActionResult.Success(); @@ -336,9 +348,9 @@ public async Task CompleteResetAsync(AccessContext conte 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); + + await _authenticationSecurityManager.UpdateAsync(failed, state.SecurityVersion, innerCt); if (context.IsAnonymousContext) return CredentialActionResult.Success(); @@ -346,8 +358,14 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthConflictException("invalid_reset_token"); } + // A valid reset proof has now been established. + // Password validation errors may be returned without + // disclosing whether an unverified account exists. + 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) @@ -358,42 +376,82 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthNotFoundException("credential_not_found"); } - if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - { - if (!context.IsAnonymousContext) - throw new UAuthValidationException("credential_secret_same"); - - // Valid reset token, but password is unchanged. - // Consume the token to preserve single-use semantics. - var version = state.SecurityVersion; - var consumed = state.ConsumeReset(now); - - await _authenticationSecurityManager.UpdateAsync( - consumed, - version, - innerCt); - - return CredentialActionResult.Success(); - } - - return await _atomicExecutor.ExecuteAsync(async atomicCt => - { - var version = state.SecurityVersion; - var consumed = state.ConsumeReset(now); - - await _authenticationSecurityManager.UpdateAsync(consumed, version, atomicCt); - - var oldVersion = pwd.Version; - var newHash = _hasher.Hash(request.NewSecret); - var updated = pwd.ChangeSecret(newHash, now); - - await store.SaveAsync(updated, oldVersion, atomicCt); - - await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, userKey, CredentialChangeReason.Recovery, actorChainId: null, - now, atomicCt); + return await _atomicExecutor.ExecuteAsync( + async atomicCt => + { + // Re-read security state within the atomic scope. + // This protects against consuming a stale proof + // after another reset operation. + var currentState = await _authenticationSecurityManager + .GetOrCreateFactorAsync( + context.ResourceTenant, + userKey, + request.CredentialType, + atomicCt); + + if (!currentState.HasActiveReset(now) || + currentState.IsResetExpired(now) || + !_tokenHasher.Verify(currentState.ResetTokenHash!, request.ResetToken)) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + + throw new UAuthConflictException("reset_request_not_active"); + } + + // Re-read the credential inside the atomic scope. + var currentCredential = await store.GetAsync( + new CredentialKey( + context.ResourceTenant, + pwd.Id), + atomicCt); + + if (currentCredential is not PasswordCredential currentPwd || + currentPwd.UserKey != userKey || + !currentPwd.Security.IsUsable(now)) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + + throw new UAuthNotFoundException("credential_not_found"); + } + + // Strength + current password + history validation. + // Validation failure throws before token consumption. + var passwordContext = await _passwordContextProvider.CreateAsync(context.ResourceTenant, userKey, request.NewSecret, atomicCt); + var effectiveServer = _effectiveServerOptionsProvider.GetEffective(context.ResourceTenant, context.ClientProfile); + var passwordOptions = PasswordPolicyOptionsResolver.Resolve(effectiveServer.Options.Password); + + try + { + await _passwordWriter.ChangeAsync( + currentPwd, + request.NewSecret, + passwordContext, + passwordOptions, + now, + atomicCt); + } + catch (UAuthValidationException) when (context.IsAnonymousContext) + { + return CredentialActionResult.Success(); + } + + var consumed = currentState.ConsumeReset(now); + + await _authenticationSecurityManager.UpdateAsync(consumed, currentState.SecurityVersion, atomicCt); + + await ApplyCredentialSessionPolicyAsync( + context.ResourceTenant, + userKey, + CredentialChangeReason.Recovery, + actorChainId: null, + now, + atomicCt); - return CredentialActionResult.Success(); - }, innerCt); + return CredentialActionResult.Success(); + }, + innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -427,25 +485,38 @@ public async Task DeleteAsync(AccessContext context, Del { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var subjectUser = context.GetTargetUserKey(); - var now = _clock.UtcNow; + var cmd = new AccessCommand( + innerCt => _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + var tenant = context.ResourceTenant; - var store = _credentialsFactory.Create(context.ResourceTenant); - var credential = await store.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var key = new CredentialKey(tenant, request.Id); - if (credential is not PasswordCredential pwd) - return CredentialActionResult.Fail("credential_not_found"); + var store = _credentialsFactory.Create(tenant); - if (pwd.UserKey != subjectUser) - return CredentialActionResult.Fail("credential_not_found"); + var credential = await store.GetAsync(key, atomicCt); - var oldVersion = pwd.Version; - await store.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); - return CredentialActionResult.Success(); - }); + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + await store.DeleteAsync(key, pwd.Version, request.Mode, now, atomicCt); + + if (request.Mode == DeleteMode.Hard) + { + var historyStore = _historyStores.Create(tenant); + + await historyStore.DeleteByCredentialAsync(key, atomicCt); + } + + return CredentialActionResult.Success(); + }, + innerCt)); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -457,8 +528,15 @@ async Task IUserCredentialsInternalService.DeleteInterna { ct.ThrowIfCancellationRequested(); - var store = _credentialsFactory.Create(tenant); - await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, ct); + await _atomicExecutor.ExecuteAsync( + async atomicCt => + { + var store = _credentialsFactory.Create(tenant); + + await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, atomicCt); + }, + ct); + return CredentialActionResult.Success(); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs new file mode 100644 index 00000000..ec5cfd5f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/PasswordCredentialWriter.cs @@ -0,0 +1,103 @@ +ο»Ώ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; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordCredentialWriter( + IPasswordValidationPipeline pipeline, + IPasswordCredentialStoreFactory credentialStores, + IPasswordHistoryStoreFactory historyStores, + IUAuthPasswordHasher hasher) +{ + public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, PasswordPolicyContext context, EffectivePasswordPolicyOptions options, + DateTimeOffset now, CancellationToken ct) + { + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = context with { Password = password } + }, + options, + ct); + + EnsureValid(result); + + var credential = PasswordCredential.Create( + id: null, + tenant: tenant, + userKey: userKey, + secretHash: hasher.Hash(password), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: now); + + await credentialStores.Create(tenant).AddAsync(credential, ct); + + return credential; + } + + public async Task ChangeAsync(PasswordCredential credential, string newPassword, PasswordPolicyContext context, EffectivePasswordPolicyOptions options, + DateTimeOffset now, CancellationToken ct) + { + var key = new CredentialKey(credential.Tenant, credential.Id); + + var result = await pipeline.ValidateAsync( + new PasswordValidationRequest + { + Context = context with + { + Password = newPassword + }, + ExistingCredentialKey = key + }, + options, + ct); + + EnsureValid(result); + + var oldVersion = credential.Version; + var oldHash = credential.SecretHash; + var newHash = hasher.Hash(newPassword); + + var updated = credential.ChangeSecret(newHash, now); + + await credentialStores.Create(credential.Tenant).SaveAsync(updated, oldVersion, ct); + + var historyStore = historyStores.Create(credential.Tenant); + + if (options.RememberLast > 0) + { + await historyStore.AddAsync( + new PasswordHistoryEntry + { + Id = Guid.NewGuid(), + Tenant = credential.Tenant, + UserKey = credential.UserKey, + CredentialKey = key, + SecretHash = oldHash, + ReplacedAt = now + }, + ct); + } + + await historyStore.PruneAsync(key, options.RememberLast, ct); + + return updated; + } + + private static void EnsureValid(PasswordPolicyResult result) + { + if (result.Succeeded) + return; + + // Preserve the existing exception-based service contract. + // The endpoint/client error mapping can be expanded later + // to expose all violations. + throw new UAuthValidationException(result.Violations[0].Code); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs new file mode 100644 index 00000000..3780ed1e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStore.cs @@ -0,0 +1,16 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryStore +{ + Task> GetRecentAsync(CredentialKey credentialKey, int count, CancellationToken ct = default); + + Task AddAsync(PasswordHistoryEntry entry, CancellationToken ct = default); + + Task PruneAsync(CredentialKey credentialKey, int keepLast, CancellationToken ct = default); + + Task DeleteByCredentialAsync(CredentialKey credentialKey, CancellationToken ct = default); + Task DeleteByUserAsync(UserKey userKey, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs new file mode 100644 index 00000000..705d4ea3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordHistoryStoreFactory.cs @@ -0,0 +1,8 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordHistoryStoreFactory +{ + IPasswordHistoryStore Create(TenantKey tenant); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj index 53803f40..83ac2e7a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Credentials diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs new file mode 100644 index 00000000..5dc76d34 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Extensions/PasswordPolicyServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Extensions; + +public static class PasswordPolicyServiceCollectionExtensions +{ + public static IServiceCollection AddPasswordStrengthPolicy(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + + return services; + } + + public static IServiceCollection ReplacePasswordStrengthPolicy(this IServiceCollection services) where TPolicy : class, IPasswordStrengthPolicy + { + services.Replace(ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs new file mode 100644 index 00000000..6ec7b0e7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/EffectivePasswordPolicyOptions.cs @@ -0,0 +1,22 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed record EffectivePasswordPolicyOptions +{ + public required PasswordPolicyLevel Level { get; init; } + + public required int MinLength { get; init; } + public required int MaxLength { get; init; } + + public required bool RequireLetter { get; init; } + public required bool RequireDigit { get; init; } + public required bool RequireUppercase { get; init; } + public required bool RequireLowercase { get; init; } + public required bool RequireSpecialCharacter { get; init; } + + public required PasswordPolicyLevel CommonPasswords { get; init; } + public required PasswordPolicyLevel UserSimilarity { get; init; } + + public required int RememberLast { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs new file mode 100644 index 00000000..36620e5c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordHistoryOptions.cs @@ -0,0 +1,15 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed class PasswordHistoryOptions +{ + /// + /// Number of previous passwords that cannot be reused. + /// Zero disables password history enforcement. + /// + public int RememberLast { get; set; } = 0; + + public PasswordHistoryOptions Clone() => new() + { + RememberLast = RememberLast + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs new file mode 100644 index 00000000..9b157da2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptions.cs @@ -0,0 +1,37 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public sealed class PasswordPolicyOptions +{ + public PasswordPolicyLevel Level { get; set; } = PasswordPolicyLevel.Standard; + + public int? MinLength { get; set; } + public int? MaxLength { get; set; } + + public bool? RequireLetter { get; set; } + public bool? RequireDigit { get; set; } + public bool? RequireUppercase { get; set; } + public bool? RequireLowercase { get; set; } + public bool? RequireSpecialCharacter { get; set; } + + public PasswordPolicyLevel? CommonPasswords { get; set; } + public PasswordPolicyLevel? UserSimilarity { get; set; } + + public PasswordHistoryOptions History { get; set; } = new(); + + public PasswordPolicyOptions Clone() => new() + { + Level = Level, + MinLength = MinLength, + MaxLength = MaxLength, + RequireLetter = RequireLetter, + RequireDigit = RequireDigit, + RequireUppercase = RequireUppercase, + RequireLowercase = RequireLowercase, + RequireSpecialCharacter = RequireSpecialCharacter, + CommonPasswords = CommonPasswords, + UserSimilarity = UserSimilarity, + History = History.Clone() + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs new file mode 100644 index 00000000..b1823e31 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/PasswordPolicyOptionsResolver.cs @@ -0,0 +1,93 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public static class PasswordPolicyOptionsResolver +{ + public static EffectivePasswordPolicyOptions Resolve(PasswordPolicyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var preset = options.Level switch + { + PasswordPolicyLevel.Basic => CreateBasic(), + PasswordPolicyLevel.Standard => CreateStandard(), + PasswordPolicyLevel.Strict => CreateStrict(), + + _ => throw new ArgumentOutOfRangeException(nameof(options), "Invalid password policy level.") + }; + + return preset with + { + MinLength = options.MinLength ?? preset.MinLength, + MaxLength = options.MaxLength ?? preset.MaxLength, + + RequireLetter = options.RequireLetter ?? preset.RequireLetter, + RequireDigit = options.RequireDigit ?? preset.RequireDigit, + RequireUppercase = options.RequireUppercase ?? preset.RequireUppercase, + RequireLowercase = options.RequireLowercase ?? preset.RequireLowercase, + RequireSpecialCharacter = options.RequireSpecialCharacter ?? preset.RequireSpecialCharacter, + CommonPasswords = options.CommonPasswords ?? preset.CommonPasswords, + UserSimilarity = options.UserSimilarity ?? preset.UserSimilarity, + RememberLast = options.History?.RememberLast ?? 0 + }; + } + + private static EffectivePasswordPolicyOptions CreateBasic() => + Create( + PasswordPolicyLevel.Basic, + minLength: 8, + requireLetter: false, + requireDigit: false, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + private static EffectivePasswordPolicyOptions CreateStandard() => + Create( + PasswordPolicyLevel.Standard, + minLength: 8, + requireLetter: true, + requireDigit: true, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + private static EffectivePasswordPolicyOptions CreateStrict() => + Create( + PasswordPolicyLevel.Strict, + minLength: 15, + requireLetter: true, + requireDigit: true, + requireUppercase: true, + requireLowercase: true, + requireSpecialCharacter: true); + + private static EffectivePasswordPolicyOptions Create( + PasswordPolicyLevel level, + int minLength, + bool requireLetter, + bool requireDigit, + bool requireUppercase, + bool requireLowercase, + bool requireSpecialCharacter) + { + return new EffectivePasswordPolicyOptions + { + Level = level, + MinLength = minLength, + MaxLength = PasswordPolicyLimits.DefaultMaxLength, + + RequireLetter = requireLetter, + RequireDigit = requireDigit, + RequireUppercase = requireUppercase, + RequireLowercase = requireLowercase, + RequireSpecialCharacter = requireSpecialCharacter, + + CommonPasswords = level, + UserSimilarity = level, + + RememberLast = 0 + }; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs new file mode 100644 index 00000000..94b80bf4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Options/UAuthPasswordPolicyInfoMapper.cs @@ -0,0 +1,29 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Options; + +public static class UAuthPasswordPolicyInfoMapper +{ + public static UAuthPasswordPolicyInfo ToInfo(this EffectivePasswordPolicyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return new UAuthPasswordPolicyInfo + { + Level = options.Level, + + MinLength = options.MinLength, + MaxLength = options.MaxLength, + + RequireLetter = options.RequireLetter, + RequireDigit = options.RequireDigit, + RequireUppercase = options.RequireUppercase, + RequireLowercase = options.RequireLowercase, + RequireSpecialCharacter = options.RequireSpecialCharacter, + + CheckCommonPasswords = options.CommonPasswords != PasswordPolicyLevel.Disabled, + CheckUserSimilarity = options.UserSimilarity != PasswordPolicyLevel.Disabled, + CheckPasswordHistory = options.RememberLast > 0 + }; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs new file mode 100644 index 00000000..fe37dde2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/BuiltInCommonPasswordChecker.cs @@ -0,0 +1,130 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +/// +/// Lightweight, offline common-password checker. +/// Provides baseline protection without an external dataset. +/// +public sealed class BuiltInCommonPasswordChecker : IPasswordCommonPasswordChecker +{ + private static readonly HashSet BasicPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "password", + "123456", + "12345678", + "123456789", + "1234567890", + "12345", + "1234567", + "12345678910", + "111111", + "000000", + "qwerty", + "qwerty123", + "abc123", + "admin", + "admin123", + "letmein", + "welcome", + "iloveyou", + "password1", + "password123" + }; + + private static readonly HashSet StandardPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "123123", + "654321", + "112233", + "121212", + "123321", + "qwertyuiop", + "asdfgh", + "asdfghjkl", + "zxcvbn", + "1q2w3e4r", + "1q2w3e4r5t", + "1qaz2wsx", + "zaq12wsx", + "passw0rd", + "p@ssw0rd", + "p@ssword", + "password!", + "password@123", + "welcome123", + "administrator", + "root123", + "test123", + "changeme", + "default", + "secret", + "secret123", + "football", + "baseball", + "monkey", + "dragon", + "sunshine", + "princess", + "superman", + "master", + "trustno1" + }; + + private static readonly HashSet StrictPasswords = + new(StringComparer.OrdinalIgnoreCase) + { + "password123!", + "password@123!", + "admin123!", + "welcome123!", + "qwerty123!", + "letmein123", + "administrator123", + "passw0rd123", + "p@ssw0rd123", + "iloveyou123", + "123456789abc", + "abc123456789", + "qwerty123456", + "asdfgh123456", + "changeme123", + "defaultpassword", + "temporarypassword", + "temporary123", + "mysecretpassword", + "supersecret", + "password2024", + "password2025", + "password2026" + }; + + public Task IsCommonAsync(string password, PasswordPolicyLevel level, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(password); + + var result = level switch + { + PasswordPolicyLevel.Disabled => false, + + PasswordPolicyLevel.Basic => + BasicPasswords.Contains(password), + + PasswordPolicyLevel.Standard => + BasicPasswords.Contains(password) || + StandardPasswords.Contains(password), + + PasswordPolicyLevel.Strict => + BasicPasswords.Contains(password) || + StandardPasswords.Contains(password) || + StrictPasswords.Contains(password), + + _ => throw new ArgumentOutOfRangeException(nameof(level)) + }; + + return Task.FromResult(result); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs new file mode 100644 index 00000000..5deb5d95 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordCommonPasswordChecker.cs @@ -0,0 +1,8 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordCommonPasswordChecker +{ + Task IsCommonAsync(string password, PasswordPolicyLevel level, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..919ecfaa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordPolicyContextProvider.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordPolicyContextProvider +{ + Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs new file mode 100644 index 00000000..ca05c92b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordStrengthPolicy.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordStrengthPolicy +{ + Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs new file mode 100644 index 00000000..81cf1a36 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/IPasswordUserSimilarityChecker.cs @@ -0,0 +1,8 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public interface IPasswordUserSimilarityChecker +{ + bool IsSimilar(PasswordPolicyContext context, PasswordPolicyLevel level); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs new file mode 100644 index 00000000..ed84767a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/PasswordPolicyContext.cs @@ -0,0 +1,10 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed record PasswordPolicyContext +{ + public required string Password { get; init; } + + public string? UserName { get; init; } + + public IReadOnlyList UserIdentifiers { get; init; } = Array.Empty(); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..1e2b3a3d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordPolicyContextProvider.cs @@ -0,0 +1,17 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class UAuthPasswordPolicyContextProvider : IPasswordPolicyContextProvider +{ + public Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(new PasswordPolicyContext + { + Password = password + }); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs new file mode 100644 index 00000000..61b5a7d4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordStrengthPolicy.cs @@ -0,0 +1,106 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class UAuthPasswordStrengthPolicy(IPasswordCommonPasswordChecker commonPasswordChecker, IPasswordUserSimilarityChecker userSimilarityChecker) : IPasswordStrengthPolicy +{ + public async Task ValidateAsync(PasswordPolicyContext context, EffectivePasswordPolicyOptions options, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + ct.ThrowIfCancellationRequested(); + + var violations = new List(); + var password = context.Password; + + if (string.IsNullOrEmpty(password)) + { + violations.Add(new(PasswordPolicyErrorCodes.Required, "Password is required.")); + + return new PasswordPolicyResult(violations); + } + + // Defensive check. The future pipeline will enforce + // the absolute input limit independently of this policy. + if (password.Length > PasswordPolicyLimits.AbsoluteMaxLength) + { + violations.Add(new(PasswordPolicyErrorCodes.InputTooLong, "Password exceeds the absolute length limit.")); + + return new PasswordPolicyResult(violations); + } + + if (password.Length < options.MinLength) + { + violations.Add(new(PasswordPolicyErrorCodes.TooShort, $"Password must contain at least {options.MinLength} characters.")); + } + + if (password.Length > options.MaxLength) + { + violations.Add(new(PasswordPolicyErrorCodes.TooLong, $"Password cannot exceed {options.MaxLength} characters.")); + } + + bool hasLetter = false; + bool hasDigit = false; + bool hasUppercase = false; + bool hasLowercase = false; + bool hasSpecialCharacter = false; + + foreach (var c in password) + { + if (char.IsLetter(c)) + hasLetter = true; + + if (char.IsDigit(c)) + hasDigit = true; + + if (char.IsUpper(c)) + hasUppercase = true; + + if (char.IsLower(c)) + hasLowercase = true; + + if (!char.IsLetterOrDigit(c)) + hasSpecialCharacter = true; + } + + if (options.RequireLetter && !hasLetter) + { + violations.Add(new(PasswordPolicyErrorCodes.LetterRequired, "Password must contain a letter.")); + } + + if (options.RequireDigit && !hasDigit) + { + violations.Add(new(PasswordPolicyErrorCodes.DigitRequired, "Password must contain a digit.")); + } + + if (options.RequireUppercase && !hasUppercase) + { + violations.Add(new(PasswordPolicyErrorCodes.UppercaseRequired, "Password must contain an uppercase letter.")); + } + + if (options.RequireLowercase && !hasLowercase) + { + violations.Add(new(PasswordPolicyErrorCodes.LowercaseRequired, "Password must contain a lowercase letter.")); + } + + if (options.RequireSpecialCharacter && !hasSpecialCharacter) + { + violations.Add(new(PasswordPolicyErrorCodes.SpecialCharacterRequired, "Password must contain a special character.")); + } + + if (options.CommonPasswords != PasswordPolicyLevel.Disabled && + await commonPasswordChecker.IsCommonAsync(password, options.CommonPasswords, ct)) + { + violations.Add(new(PasswordPolicyErrorCodes.CommonPassword, "Password is too common.")); + } + + if (options.UserSimilarity != PasswordPolicyLevel.Disabled && userSimilarityChecker.IsSimilar(context, options.UserSimilarity)) + { + violations.Add(new(PasswordPolicyErrorCodes.SimilarToUser, "Password is too similar to user information.")); + } + + return new PasswordPolicyResult(violations); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs new file mode 100644 index 00000000..e6c35fa1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Policies/UAuthPasswordUserSimilarityChecker.cs @@ -0,0 +1,113 @@ +ο»Ώusing System.Text; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class UAuthPasswordUserSimilarityChecker : IPasswordUserSimilarityChecker +{ + public bool IsSimilar(PasswordPolicyContext context, PasswordPolicyLevel level) + { + ArgumentNullException.ThrowIfNull(context); + + if (level == PasswordPolicyLevel.Disabled) + return false; + + if (level is not ( + PasswordPolicyLevel.Basic or + PasswordPolicyLevel.Standard or + PasswordPolicyLevel.Strict)) + { + throw new ArgumentOutOfRangeException(nameof(level)); + } + + var password = context.Password; + + if (string.IsNullOrEmpty(password)) + return false; + + var candidates = GetCandidates(context); + + foreach (var candidate in candidates) + { + if (string.Equals(password, candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (level >= PasswordPolicyLevel.Standard && candidate.Length >= 4 && password.Contains(candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (level == PasswordPolicyLevel.Strict && candidate.Length >= 4 && IsStrictMatch(password, candidate)) + { + return true; + } + } + + return false; + } + + private static IEnumerable GetCandidates(PasswordPolicyContext context) + { + var values = new List(); + + if (!string.IsNullOrWhiteSpace(context.UserName)) + values.Add(context.UserName); + + values.AddRange(context.UserIdentifiers ?? Array.Empty()); + + var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var value in values) + { + var trimmed = value.Trim(); + + if (trimmed.Length == 0) + continue; + + candidates.Add(trimmed); + + var atIndex = trimmed.IndexOf('@'); + + if (atIndex > 0) + candidates.Add(trimmed[..atIndex]); + } + + return candidates; + } + + private static bool IsStrictMatch(string password, string candidate) + { + var normalizedPassword = Normalize(password); + var normalizedCandidate = Normalize(candidate); + + return normalizedCandidate.Length >= 4 && normalizedPassword.Contains(normalizedCandidate, StringComparison.Ordinal); + } + + private static string Normalize(string value) + { + var builder = new StringBuilder(value.Length); + + foreach (var c in value.ToLowerInvariant()) + { + var normalized = c switch + { + '0' => 'o', + '1' => 'i', + '3' => 'e', + '4' => 'a', + '5' => 's', + '7' => 't', + '@' => 'a', + '$' => 's', + _ => c + }; + + if (char.IsLetterOrDigit(normalized)) + builder.Append(normalized); + } + + return builder.ToString(); + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj index 11ada993..cf84ac1b 100644 --- a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.EntityFrameworkCore @@ -18,16 +18,16 @@ - - + + - - + + - - + + diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj b/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj index ca100aa8..8ef46540 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.InMemory diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index 6f703658..13c2f88f 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Policies 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 1ed21519..d7b51946 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Security.Argon2 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 345afa17..69308198 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 9ac9e960..58e7d514 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -867,4 +867,21 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can rootDomain.UpdateProjection(rootProjection); rootProjection.Version++; } + + public async Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chainIds.Count == 0) + return new Dictionary(); + + var ids = chainIds.Distinct().ToArray(); + + return await DbSetSession + .AsNoTracking() + .Where(s => s.Tenant == _tenant && ids.Contains(s.ChainId)) + .GroupBy(s => s.ChainId) + .Select(g => new { ChainId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ChainId, x => x.Count, ct); + } } 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 09a0ea22..e2a8e517 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Sessions.InMemory diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index d18421e3..0b4225eb 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -574,4 +574,23 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat return Task.CompletedTask; } + + public Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chainIds.Count == 0) + { + return Task.FromResult>(new Dictionary()); + } + + var ids = chainIds.ToHashSet(); + + IReadOnlyDictionary counts = _sessions.Values + .Where(s => ids.Contains(s.ChainId)) + .GroupBy(s => s.ChainId) + .ToDictionary(g => g.Key, g => g.Count()); + + return Task.FromResult(counts); + } } 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 6d1c5e19..cb3f075e 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore 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 4be63678..708c17cd 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Tokens.InMemory 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 8ff8aa91..3bcc4e6e 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Users.Contracts diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileLimits.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileLimits.cs new file mode 100644 index 00000000..237530d9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileLimits.cs @@ -0,0 +1,24 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Users.Contracts; + +/// +/// Framework-level safety limits for user profile content. +/// Applications may enforce stricter limits but cannot increase these limits. +/// +public static class UserProfileLimits +{ + public const int FirstName = 128; + public const int LastName = 128; + public const int DisplayName = 256; + + public const int Gender = 128; + public const int Bio = 4096; + + public const int Language = 64; + public const int TimeZone = 128; + public const int Culture = 64; + + public const int MetadataEntries = 32; + public const int MetadataKey = 128; + public const int MetadataValue = 2048; + public const int MetadataTotalUtf8Bytes = 8192; +} 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 ab0912de..6ffa4e97 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Users.EntityFrameworkCore diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs index f011d9ce..bd2c0fa5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs @@ -117,6 +117,15 @@ private static void ConfigureProfiles(ModelBuilder b) .HasMaxLength(64) .IsRequired(); + e.Property(x => x.FirstName).HasMaxLength(UserProfileLimits.FirstName); + e.Property(x => x.LastName).HasMaxLength(UserProfileLimits.LastName); + e.Property(x => x.DisplayName).HasMaxLength(UserProfileLimits.DisplayName); + e.Property(x => x.Gender).HasMaxLength(UserProfileLimits.Gender); + e.Property(x => x.Bio).HasMaxLength(UserProfileLimits.Bio); + e.Property(x => x.Language).HasMaxLength(UserProfileLimits.Language); + e.Property(x => x.TimeZone).HasMaxLength(UserProfileLimits.TimeZone); + e.Property(x => x.Culture).HasMaxLength(UserProfileLimits.Culture); + e.Property(x => x.Metadata) .HasConversion(new NullableJsonValueConverter>()) .Metadata.SetValueComparer(JsonValueComparers.Create>()); @@ -128,4 +137,4 @@ private static void ConfigureProfiles(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.UserKey, x.ProfileKey }).IsUnique(); }); } -} \ No newline at end of file +} 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 0d7ed5fb..8581ea8a 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Users.InMemory 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 3b4b5061..db838103 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 @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Users.Reference diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 35c99919..1ba6d18c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -1,8 +1,9 @@ -ο»Ώusing CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Core.Abstractions; +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; public static class ServiceCollectionExtensions @@ -22,6 +23,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.AddScoped(); + services.AddScoped(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs new file mode 100644 index 00000000..886bba48 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserPasswordPolicyContextProvider.cs @@ -0,0 +1,39 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserPasswordPolicyContextProvider(IUserIdentifierStoreFactory storeFactory) : IPasswordPolicyContextProvider +{ + public async Task CreateAsync(TenantKey tenant, UserKey userKey, string password, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var store = storeFactory.Create(tenant); + + var identifiers = await store.GetByUserAsync(userKey, ct); + + var active = identifiers.Where(x => !x.IsDeleted).ToArray(); + + var userName = active + .Where(x => x.Type == UserIdentifierType.Username) + .OrderByDescending(x => x.IsPrimary) + .Select(x => x.Value) + .FirstOrDefault(); + + var values = active + .Select(x => x.Value) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new PasswordPolicyContext + { + Password = password, + UserName = userName, + UserIdentifiers = values + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 12036f54..20a32026 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -7,7 +7,6 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; -using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; @@ -77,6 +76,22 @@ public async Task CreateUserAsync(AccessContext context, Creat throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); } + UserProfileSafetyValidation.EnsureValid(new UserProfileInfo + { + 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, + Metadata = request.Metadata + }); + + EnsureCreateUserIdentifierLimits(request); + return await _atomicExecutor.ExecuteAsync( async atomicCt => { @@ -160,7 +175,7 @@ await identifierStore.AddAsync( foreach (var integration in _integrations) { // Credential creation handle on here - await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, atomicCt); + await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, context.ClientProfile, atomicCt); } return UserCreateResult.Success(userKey); @@ -465,6 +480,7 @@ public async Task CreateProfileAsync(AccessContext context, CreateProfileRequest metadata: request.Metadata); } + UserProfileSafetyValidation.EnsureValid(profile.ToDto()); await store.AddAsync(profile, innerCt); }); @@ -490,6 +506,21 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq if (!_options.UserProfile.EnableMultiProfile && profileKey != ProfileKey.Default) throw new UAuthConflictException("multi_profile_disabled"); + UserProfileSafetyValidation.EnsureValid(new UserProfileInfo + { + ProfileKey = profileKey, + 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 + }); + var expectedVersion = profile.Version; profile @@ -625,6 +656,11 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie var existing = await identifierStore.GetByUserAsync(userKey, innerCt); EnsureMultipleIdentifierAllowed(request.Type, existing); + // TODO: Enforce identifier limits atomically for concurrent additions. + // The current read-check-insert sequence can exceed configured limits when multiple requests add identifiers to the same user simultaneously. + // Introduce per-user synchronization or a persistence-level guarantee for both EF Core and InMemory implementations. + EnsureIdentifierLimits(request.Type, existing); + var userScopeResult = await identifierStore.ExistsAsync( new IdentifierExistenceQuery(request.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, UserKey: userKey), innerCt); @@ -980,6 +1016,28 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL throw new UAuthValidationException("multiple_phones_not_allowed"); } + private void EnsureIdentifierLimits(UserIdentifierType type, IReadOnlyList existing) + { + var behavior = _options.Identifiers.Behavior; + + var activeIdentifiers = existing.Where(i => !i.IsDeleted).ToList(); + + if (activeIdentifiers.Count >= behavior.MaxIdentifiersPerUser) + { + throw new UAuthIdentifierValidationException("identifier_total_limit_exceeded"); + } + + if (behavior.MaxIdentifiersPerType.TryGetValue(type, out var maxPerType)) + { + var activeTypeCount = activeIdentifiers.Count(i => i.Type == type); + + if (activeTypeCount >= maxPerType) + { + throw new UAuthIdentifierValidationException("identifier_type_limit_exceeded"); + } + } + } + private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { if (type == UserIdentifierType.Email && _options.Identifiers.Behavior.RequireEmailVerification && !isVerified) @@ -1057,6 +1115,35 @@ private async Task EnsureIdentifierUniquenessAsync( } } + private void EnsureCreateUserIdentifierLimits(CreateUserRequest request) + { + var behavior = _options.Identifiers.Behavior; + + var types = new List(3); + + if (!string.IsNullOrWhiteSpace(request.UserName)) + types.Add(UserIdentifierType.Username); + + if (!string.IsNullOrWhiteSpace(request.Email)) + types.Add(UserIdentifierType.Email); + + if (!string.IsNullOrWhiteSpace(request.Phone)) + types.Add(UserIdentifierType.Phone); + + if (types.Count > behavior.MaxIdentifiersPerUser) + { + throw new UAuthIdentifierValidationException("identifier_total_limit_exceeded"); + } + + foreach (var group in types.GroupBy(type => type)) + { + if (behavior.MaxIdentifiersPerType.TryGetValue(group.Key, out var maxPerType) && group.Count() > maxPerType) + { + throw new UAuthIdentifierValidationException("identifier_type_limit_exceeded"); + } + } + } + // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. private UniquenessScope GetUniquenessScope(UserIdentifierType type) { diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs index c07c0ac8..36673ec4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Users; @@ -10,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Users; /// public interface IUserLifecycleIntegration { - Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct = default); + Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, UAuthClientProfile clientProfile, CancellationToken ct = default); Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, 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 9dd91208..4a454f08 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0;net9.0;net10.0 + $(UltimateAuthBuildTargetFrameworks) $(NoWarn);1591 CodeBeam.UltimateAuth.Users diff --git a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj index cdce659b..bcef26b9 100644 --- a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj +++ b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs new file mode 100644 index 00000000..0378b5a6 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/AssemblyBehavior.cs @@ -0,0 +1,3 @@ +ο»Ώusing Xunit.v3; + +[assembly: Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs index 818b4c05..91aef379 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs @@ -26,11 +26,11 @@ public async Task Owner_cannot_access_foreign_or_missing_targets(string action) foreach (var target in new UserKey?[] { LabDatabase.MemberB, LabDatabase.OwnerB, UserKey.FromString("nonexistent"), null }) { - var denied = await policy.DecideAsync(Contexts.Create(action, LabDatabase.OwnerA, target)); + var denied = await policy.DecideAsync(Contexts.Create(action, LabDatabase.OwnerA, target), TestContext.Current.CancellationToken); Assert.True(denied.IsDenied); } Assert.True((await policy.DecideAsync(Contexts.Create(action, - LabDatabase.OwnerA, LabDatabase.MemberA))).IsAllowed); + LabDatabase.OwnerA, LabDatabase.MemberA), TestContext.Current.CancellationToken)).IsAllowed); Assert.Equal(before, await db.SnapshotAsync()); } @@ -44,9 +44,9 @@ public async Task Inactive_sandbox_blocks_writes_and_suspension_also_blocks_read await db.SetStatusAsync(status); var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new()); Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.UserProfiles.UpdateAdmin, - LabDatabase.OwnerA, LabDatabase.MemberA))).IsDenied); + LabDatabase.OwnerA, LabDatabase.MemberA), TestContext.Current.CancellationToken)).IsDenied); Assert.Equal(readDenied, (await policy.DecideAsync(Contexts.Create(UAuthActions.UserProfiles.GetAdmin, - LabDatabase.OwnerA, LabDatabase.MemberA))).IsDenied); + LabDatabase.OwnerA, LabDatabase.MemberA), TestContext.Current.CancellationToken)).IsDenied); } [Fact] @@ -56,11 +56,11 @@ public async Task Members_cannot_manage_users_and_owners_cannot_be_deleted() await db.SeedAsync(); var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new()); Assert.Equal("sandbox_owner_required", (await policy.DecideAsync(Contexts.Create( - UAuthActions.Users.DeleteAdmin, LabDatabase.MemberA, LabDatabase.OwnerA))).DenyReason); + UAuthActions.Users.DeleteAdmin, LabDatabase.MemberA, LabDatabase.OwnerA), TestContext.Current.CancellationToken)).DenyReason); Assert.Equal("sandbox_owner_cannot_be_deleted", (await policy.DecideAsync(Contexts.Create( - UAuthActions.Users.DeleteAdmin, LabDatabase.OwnerA, LabDatabase.OwnerA))).DenyReason); + UAuthActions.Users.DeleteAdmin, LabDatabase.OwnerA, LabDatabase.OwnerA), TestContext.Current.CancellationToken)).DenyReason); Assert.Equal("sandbox_owner_cannot_delete_self", (await policy.DecideAsync(Contexts.Create( - UAuthActions.Users.DeleteSelf, LabDatabase.OwnerA, LabDatabase.OwnerA))).DenyReason); + UAuthActions.Users.DeleteSelf, LabDatabase.OwnerA, LabDatabase.OwnerA), TestContext.Current.CancellationToken)).DenyReason); } [Fact] @@ -71,8 +71,8 @@ public async Task Batch_read_checks_every_target_and_requires_nonempty_targets() var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new()); foreach (var targets in new[] { Array.Empty(), new[] { LabDatabase.MemberA, LabDatabase.MemberB } }) Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.Users.GetByKeysAdmin, - LabDatabase.OwnerA, targets: targets))).IsDenied); + LabDatabase.OwnerA, targets: targets), TestContext.Current.CancellationToken)).IsDenied); Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.Users.GetByKeysAdmin, - LabDatabase.OwnerA, targets: new[] { LabDatabase.MemberA, LabDatabase.OwnerA }))).IsAllowed); + LabDatabase.OwnerA, targets: new[] { LabDatabase.MemberA, LabDatabase.OwnerA }), TestContext.Current.CancellationToken)).IsAllowed); } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj index a33d9b29..7f863920 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj @@ -1,18 +1,16 @@ - +ο»Ώ - net10.0 false true - - - - - - - + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs index a9175b3d..dbc99af6 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs @@ -1,7 +1,8 @@ -using System.Reflection; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using System.Reflection; namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure; @@ -9,13 +10,35 @@ internal static class Contexts { // AccessContext deliberately has no public constructor. Keep reflection confined // to this adapter rather than adding production friend assemblies or public APIs. - public static AccessContext Create(string action, UserKey? actor = null, UserKey? target = null, - bool system = false, IReadOnlyDictionary? attributes = null, - IReadOnlyCollection? targets = null) - => (AccessContext)typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) - .Single().Invoke(new object?[] { actor, TenantKeys.Single, actor.HasValue, system, - null, Array.Empty(), "identitylab.tests", target, TenantKeys.Single, - action, attributes ?? new Dictionary(), targets }); + public static AccessContext Create(string action, UserKey? actor = null, UserKey? target = null, bool system = false, + IReadOnlyDictionary? attributes = null, IReadOnlyCollection? targets = null) + { + var constructor = typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Single(); + + var args = constructor.GetParameters() + .Select(parameter => parameter.Name switch + { + "actorUserKey" => (object?)actor, + "actorTenant" => TenantKeys.Single, + "isAuthenticated" => actor.HasValue, + "isSystemActor" => system, + "actorChainId" => null, + "actorRoles" => Array.Empty(), + "resource" => "identitylab.tests", + "targetUserKey" => target, + "resourceTenant" => TenantKeys.Single, + "action" => action, + "attributes" => attributes ?? new Dictionary(), + "targetUserKeys" => targets, + "clientProfile" => UAuthClientProfile.NotSpecified, + _ => throw new InvalidOperationException( + $"Unexpected AccessContext parameter: {parameter.Name}") + }) + .ToArray(); + + return (AccessContext)constructor.Invoke(args); + } + public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlow(UserKey actor) { var constructor = typeof(CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext) @@ -29,5 +52,4 @@ public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlo }).ToArray(); return (CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext)constructor.Invoke(args); } - } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs index bf45c506..3f2936e0 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs @@ -12,7 +12,7 @@ public sealed class PostgresFixture : IAsyncLifetime { private PostgreSqlContainer? container; private string connectionString = ""; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { connectionString = Environment.GetEnvironmentVariable("IDENTITYLAB_TEST_POSTGRES") ?? ""; if (string.IsNullOrWhiteSpace(connectionString)) @@ -36,7 +36,7 @@ public async Task CreateAsync() await db.Database.MigrateAsync(); return database; } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (container is not null) await container.DisposeAsync(); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs index 51d0566f..67f6757d 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs @@ -26,33 +26,33 @@ public async Task Registration_persists_account_owner_role_and_sandbox_without_l var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), new(db), new(db, NullLogger.Instance)); - var result = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + var result = await registration.RegisterAsync(new("alice", "alice@example.test", "test-password-8!"), TestContext.Current.CancellationToken); Assert.True(result.IsSuccess, result.ErrorMessage); UserKey owner; await using (var verify = db.CreateDbContext()) { - var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(); + var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(TestContext.Current.CancellationToken); owner = sandbox.OwnerUserKey; Assert.Equal(owner, Assert.Single(sandbox.Members).UserKey); } // Query actual framework stores, using a new scope to avoid tracked state. await using var verification = runtime.Services.CreateAsyncScope(); var assignments = await verification.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAssignmentsAsync(owner); + .Create(TenantKeys.Single).GetAssignmentsAsync(owner, TestContext.Current.CancellationToken); Assert.Contains(assignments, x => x.RoleId == LabRoleIds.Owner); Assert.DoesNotContain(assignments, x => x.RoleId == LabRoleIds.Admin); var lifecycle = await verification.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, owner)); + .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, owner), TestContext.Current.CancellationToken); Assert.NotNull(lifecycle); var duplicate = await Record.ExceptionAsync(async () => { - var duplicateResult = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + var duplicateResult = await registration.RegisterAsync(new("alice", "alice@example.test", "test-password-8!"), TestContext.Current.CancellationToken); Assert.False(duplicateResult.IsSuccess); }); // Duplicate validation may throw or return failure depending on the framework contract. if (duplicate is not null) Assert.IsType(duplicate); await using var final = db.CreateDbContext(); - Assert.Equal(1, await final.Sandboxes.CountAsync()); + Assert.Equal(1, await final.Sandboxes.CountAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -62,14 +62,14 @@ public async Task Trusted_seed_is_repeatable_and_assigns_ladmin_only_to_fixed_ad await using var runtime = await LabRuntime.CreateAsync(db); await using var scope = runtime.Services.CreateAsyncScope(); var sp = scope.ServiceProvider; - await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single); - await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single); + await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single, TestContext.Current.CancellationToken); + await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single, TestContext.Current.CancellationToken); var store = sp.GetRequiredService().Create(TenantKeys.Single); - var assignments = await store.GetAssignmentsAsync(LabAdminIdentity.UserKey); + var assignments = await store.GetAssignmentsAsync(LabAdminIdentity.UserKey, TestContext.Current.CancellationToken); Assert.Single(assignments, x => x.RoleId == LabRoleIds.Admin); - Assert.Equal(1, await store.CountAssignmentsAsync(LabRoleIds.Admin)); + Assert.Equal(1, await store.CountAssignmentsAsync(LabRoleIds.Admin, TestContext.Current.CancellationToken)); var roleStore = sp.GetRequiredService().Create(TenantKeys.Single); - var manager = await roleStore.GetByNameAsync("MANAGER"); + var manager = await roleStore.GetByNameAsync("MANAGER", TestContext.Current.CancellationToken); Assert.NotNull(manager); Assert.Equal(LabRoleIds.Manager, manager.Id); Assert.True(manager.Permissions.Select(x => x.Value).ToHashSet().SetEquals(LabDemoPermissions.Manager)); @@ -85,14 +85,14 @@ public async Task Actual_role_service_denies_user_escalation_without_changing_pe await using var scope = runtime.Services.CreateAsyncScope(); var service = scope.ServiceProvider.GetRequiredService(); var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); - var before = await store.GetAssignmentsAsync(LabDatabase.MemberA); + var before = await store.GetAssignmentsAsync(LabDatabase.MemberA, TestContext.Current.CancellationToken); var context = Contexts.Create(CodeBeam.UltimateAuth.Core.Defaults.UAuthActions.Authorization.Roles.AssignAdmin, LabDatabase.MemberA, LabDatabase.MemberA); await Assert.ThrowsAsync(() => - service.AssignAsync(context, LabDatabase.MemberA, role)); + service.AssignAsync(context, LabDatabase.MemberA, role, TestContext.Current.CancellationToken)); await using var verification = runtime.Services.CreateAsyncScope(); var after = await verification.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAssignmentsAsync(LabDatabase.MemberA); + .Create(TenantKeys.Single).GetAssignmentsAsync(LabDatabase.MemberA, TestContext.Current.CancellationToken); Assert.Equal(before.Select(x => x.RoleId).OrderBy(x => x.Value), after.Select(x => x.RoleId).OrderBy(x => x.Value)); } @@ -108,31 +108,31 @@ public async Task Owner_can_provision_member_and_assign_demo_role_but_cannot_del var assignments = sp.GetRequiredService(); var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, new(db), new(db, NullLogger.Instance)); - Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "test-password-8!"), TestContext.Current.CancellationToken)).IsSuccess); UserKey owner; - await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; + await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync(TestContext.Current.CancellationToken)).OwnerUserKey; var provider = ActorProvider.For(owner); var provisioning = new LabUserProvisioningService(provider, new(db), new(db), users, roles, assignments, sp.GetRequiredService(), NullLogger.Instance); var result = await provisioning.CreateMemberAsync(owner, new CreateUserRequest - { UserName = "member", Email = "member@example.test", Password = "Member-test-password-8!" }); + { UserName = "member", Email = "member@example.test", Password = "test-password-8!" }, TestContext.Current.CancellationToken); Assert.True(result.IsSuccess, result.FailureReason); var member = UserKey.FromString(result.UserKey!); var demos = new LabDemoRoleService(provider, db, roles, assignments); - await demos.SetRoleAsync(member, "Manager", true); - await Assert.ThrowsAsync(() => demos.SetRoleAsync(member, "Ladmin", true)); + await demos.SetRoleAsync(member, "Manager", true, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => demos.SetRoleAsync(member, "Ladmin", true, ct: TestContext.Current.CancellationToken)); var roleService = sp.GetRequiredService(); await Assert.ThrowsAsync(() => roleService.AssignAsync(Contexts.Create(CodeBeam.UltimateAuth.Core.Defaults.UAuthActions.Authorization.Roles.AssignAdmin, - owner, member), member, "Ladmin")); + owner, member), member, "Ladmin", TestContext.Current.CancellationToken)); await using var fresh = runtime.Services.CreateAsyncScope(); var persisted = await fresh.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAssignmentsAsync(member); + .Create(TenantKeys.Single).GetAssignmentsAsync(member, TestContext.Current.CancellationToken); Assert.Contains(persisted, x => x.RoleId == LabRoleIds.User); Assert.Contains(persisted, x => x.RoleId == LabRoleIds.Manager); Assert.DoesNotContain(persisted, x => x.RoleId == LabRoleIds.Admin || x.RoleId == LabRoleIds.Owner); await using var final = db.CreateDbContext(); - Assert.Equal(member, (await final.SandboxMembers.SingleAsync(x => x.UserKey == member)).UserKey); + Assert.Equal(member, (await final.SandboxMembers.SingleAsync(x => x.UserKey == member, TestContext.Current.CancellationToken)).UserKey); } [Fact] @@ -144,21 +144,21 @@ public async Task Owner_cannot_rename_delete_or_change_permissions_of_system_rol await using var scope = runtime.Services.CreateAsyncScope(); var roles = scope.ServiceProvider.GetRequiredService(); var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); - var before = await store.GetByNameAsync("LADMIN"); + var before = await store.GetByNameAsync("LADMIN", TestContext.Current.CancellationToken); Assert.NotNull(before); CodeBeam.UltimateAuth.Core.Contracts.AccessContext Context(string action) => Contexts.Create(action, LabDatabase.OwnerA); await Assert.ThrowsAsync(() => - roles.RenameAsync(Context("authorization.roles.rename.admin"), LabRoleIds.Admin, "Manager")); + roles.RenameAsync(Context("authorization.roles.rename.admin"), LabRoleIds.Admin, "Manager", TestContext.Current.CancellationToken)); await Assert.ThrowsAsync(() => roles.DeleteAsync(Context("authorization.roles.delete.admin"), LabRoleIds.Admin, - CodeBeam.UltimateAuth.Core.Contracts.DeleteMode.Hard)); + CodeBeam.UltimateAuth.Core.Contracts.DeleteMode.Hard, TestContext.Current.CancellationToken)); await Assert.ThrowsAsync(() => roles.SetPermissionsAsync(Context("authorization.roles.permissions.admin"), LabRoleIds.Admin, - Array.Empty())); + Array.Empty(), TestContext.Current.CancellationToken)); await using var fresh = runtime.Services.CreateAsyncScope(); var after = await fresh.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetByNameAsync("LADMIN"); + .Create(TenantKeys.Single).GetByNameAsync("LADMIN", TestContext.Current.CancellationToken); Assert.NotNull(after); Assert.Equal(before.Id, after.Id); Assert.Equal(before.Version, after.Version); @@ -179,9 +179,9 @@ public async Task Provisioning_assignment_fault_hard_deletes_real_partial_accoun var assignments = sp.GetRequiredService(); var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, new(db), new(db, NullLogger.Instance)); - Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "test-password-8!"), TestContext.Current.CancellationToken)).IsSuccess); UserKey owner; - await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; + await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync(cancellationToken: TestContext.Current.CancellationToken)).OwnerUserKey; var before = await db.SnapshotAsync(); var failedStore = new Moq.Mock(); failedStore.Setup(x => x.GetAssignmentsAsync(Moq.It.IsAny(), Moq.It.IsAny())) @@ -192,14 +192,14 @@ public async Task Provisioning_assignment_fault_hard_deletes_real_partial_accoun roles, failedFactory.Object, sp.GetRequiredService(), NullLogger.Instance); var failure = await Assert.ThrowsAsync(() => provisioning.CreateMemberAsync(owner, - new CreateUserRequest { UserName = "failed-member", Email = "failed-member@example.test", Password = "Member-test-password-8!" })); + new CreateUserRequest { UserName = "failed-member", Email = "failed-member@example.test", Password = "Member-test-password-8!" }, TestContext.Current.CancellationToken)); var partialKey = UserKey.FromString(failure.CreatedUserKey); await using var fresh = runtime.Services.CreateAsyncScope(); var lifecycle = await fresh.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, partialKey)); + .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, partialKey), TestContext.Current.CancellationToken); Assert.Null(lifecycle); var remainingRoles = await fresh.ServiceProvider.GetRequiredService() - .Create(TenantKeys.Single).GetAssignmentsAsync(partialKey); + .Create(TenantKeys.Single).GetAssignmentsAsync(partialKey, TestContext.Current.CancellationToken); Assert.Empty(remainingRoles); Assert.Equal(before, await db.SnapshotAsync()); } @@ -213,19 +213,18 @@ public async Task Actual_context_factory_replaces_forged_ladmin_attributes_with_ await using var scope = runtime.Services.CreateAsyncScope(); var sp = scope.ServiceProvider; await new LabRoleService(sp.GetRequiredService(), sp.GetRequiredService()) - .AssignAsync(TenantKeys.Single, LabDatabase.OwnerA, "Owner"); + .AssignAsync(TenantKeys.Single, LabDatabase.OwnerA, "Owner", TestContext.Current.CancellationToken); var context = await sp.GetRequiredService() .CreateAsync(Contexts.AuthenticatedFlow(LabDatabase.OwnerA), "users.delete.admin", "users", - LabDatabase.MemberB.Value, new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); + LabDatabase.MemberB.Value, new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }, TestContext.Current.CancellationToken); Assert.False(new LabRootResolver().IsLadmin(context)); Assert.Equal(LabDatabase.OwnerA, context.ActorUserKey); var resolved = Assert.IsAssignableFrom>(context.Attributes["roles"]); Assert.Contains(LabRoleIds.Owner, resolved); Assert.DoesNotContain(LabRoleIds.Admin, resolved); var decision = await new CodeBeam.UltimateAuth.IdentityLab.Policy.SandboxUAuthAccessPolicy(db, - new LabRootResolver(), sp.GetRequiredService()).DecideAsync(context); + new LabRootResolver(), sp.GetRequiredService()).DecideAsync(context, TestContext.Current.CancellationToken); Assert.True(decision.IsDenied); Assert.Equal("sandbox_target_out_of_scope", decision.DenyReason); } - } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs index 2cfe13d0..f84054aa 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs @@ -11,9 +11,9 @@ public sealed class SandboxPersistenceTests(PostgresFixture postgres) : IClassFi public async Task Creation_persists_owner_membership_and_settings_together() { await using var db = await postgres.CreateAsync(); - var sandbox = await new SandboxService(db).CreateAsync(LabDatabase.OwnerA, " Sandbox A "); + var sandbox = await new SandboxService(db).CreateAsync(LabDatabase.OwnerA, " Sandbox A ", TestContext.Current.CancellationToken); await using var verify = db.CreateDbContext(); - var actual = await verify.Sandboxes.Include(x => x.Members).Include(x => x.Settings).SingleAsync(); + var actual = await verify.Sandboxes.Include(x => x.Members).Include(x => x.Settings).SingleAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(sandbox.Id, actual.Id); Assert.Equal("Sandbox A", actual.Name); Assert.Equal(LabDatabase.OwnerA, actual.OwnerUserKey); @@ -30,14 +30,14 @@ public async Task Cross_sandbox_member_removal_and_owner_removal_leave_database_ await db.SeedAsync(); var before = await db.SnapshotAsync(); var service = new SandboxService(db); - Assert.False(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB)); - await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.OwnerA)); - await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.MemberA, LabDatabase.MemberA)); + Assert.False(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.OwnerA, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.MemberA, LabDatabase.MemberA, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); - Assert.True(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA)); + Assert.True(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA, TestContext.Current.CancellationToken)); await using var verify = db.CreateDbContext(); - Assert.False(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA)); - Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB)); + Assert.False(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA, cancellationToken: TestContext.Current.CancellationToken)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -46,7 +46,7 @@ public async Task Duplicate_owner_creation_rolls_back_all_new_sandbox_state() await using var db = await postgres.CreateAsync(); await db.SeedAsync(); var before = await db.SnapshotAsync(); - await Assert.ThrowsAsync(() => new SandboxService(db).CreateAsync(LabDatabase.OwnerA, "Duplicate")); + await Assert.ThrowsAsync(() => new SandboxService(db).CreateAsync(LabDatabase.OwnerA, "Duplicate", TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); } @@ -57,9 +57,9 @@ public async Task Concurrent_member_additions_cannot_exceed_capacity() await db.SeedAsync(); await using (var setup = db.CreateDbContext()) { - var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA); + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA, cancellationToken: TestContext.Current.CancellationToken); settings.MaxUsers = 3; - await setup.SaveChangesAsync(); + await setup.SaveChangesAsync(TestContext.Current.CancellationToken); } var service = new SandboxService(db); async Task Add(string key) => await Record.ExceptionAsync(async () => @@ -68,8 +68,8 @@ public async Task Concurrent_member_additions_cannot_exceed_capacity() var outcomes = await Task.WhenAll(Add("candidate-1"), Add("candidate-2")); Assert.Single(outcomes, x => x is null); await using var verify = db.CreateDbContext(); - Assert.Equal(3, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA)); - Assert.Equal(2, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerB)); + Assert.Equal(3, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(2, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerB, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -78,7 +78,7 @@ public async Task Member_cannot_be_added_to_second_sandbox() await using var db = await postgres.CreateAsync(); await db.SeedAsync(); var before = await db.SnapshotAsync(); - await Assert.ThrowsAsync(() => new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB)); + await Assert.ThrowsAsync(() => new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); } [Fact] @@ -91,9 +91,45 @@ public async Task Concurrent_creation_cannot_create_two_sandboxes_for_one_owner( var outcomes = await Task.WhenAll(Create("First"), Create("Second")); Assert.Single(outcomes, x => x is null); await using var verify = db.CreateDbContext(); - Assert.Equal(1, await verify.Sandboxes.CountAsync()); - Assert.Equal(1, await verify.SandboxMembers.CountAsync()); - Assert.Equal(1, await verify.SandboxSettings.CountAsync()); + Assert.Equal(1, await verify.Sandboxes.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(1, await verify.SandboxMembers.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(1, await verify.SandboxSettings.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Ensure_restores_owner_relationship_without_changing_existing_sandbox(bool missingMembership) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var service = new SandboxService(db); + var original = (await service.GetByUserAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken))!; + await using (var setup = db.CreateDbContext()) + { + var owner = await setup.SandboxMembers.SingleAsync(x => x.UserKey == LabDatabase.OwnerA, cancellationToken: TestContext.Current.CancellationToken); + if (missingMembership) + setup.SandboxMembers.Remove(owner); + else + owner.Role = SandboxMemberRole.Member; + await setup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + var repaired = await service.EnsureAsync(LabDatabase.OwnerA, "Must not replace existing name", TestContext.Current.CancellationToken); + Assert.Equal(original.Id, repaired.Id); + Assert.Equal(original.Name, repaired.Name); + Assert.Equal(original.Settings.MaxUsers, repaired.Settings.MaxUsers); + var beforeRepeat = await db.SnapshotAsync(); + await service.EnsureAsync(LabDatabase.OwnerA, "Still must not rename", TestContext.Current.CancellationToken); + Assert.Equal(beforeRepeat, await db.SnapshotAsync()); + await using var verify = db.CreateDbContext(); + var members = await verify.SandboxMembers.ToListAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(4, members.Count); + Assert.Equal(SandboxMemberRole.Owner, Assert.Single(members, x => x.UserKey == LabDatabase.OwnerA).Role); + Assert.Contains(members, x => x.UserKey == LabDatabase.MemberA && x.SandboxId == original.Id); + Assert.Contains(members, x => x.UserKey == LabDatabase.OwnerB && x.Role == SandboxMemberRole.Owner); + Assert.Contains(members, x => x.UserKey == LabDatabase.MemberB && x.SandboxId != original.Id); + Assert.Equal(2, await verify.Sandboxes.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(2, await verify.SandboxSettings.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs new file mode 100644 index 00000000..79926be4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs @@ -0,0 +1,193 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; + +public sealed class UserWorkflowTests(PostgresFixture postgres) : IClassFixture +{ + private static async Task RegisterAsync(IServiceProvider sp, LabDatabase db, string name) + { + var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + new(db), new(db, NullLogger.Instance)); + var result = await registration.RegisterAsync(new(name, $"{name}@example.test", "test-password-8!")); + Assert.True(result.IsSuccess, result.ErrorMessage); + await using var verify = db.CreateDbContext(); + return (await verify.Sandboxes.SingleAsync(x => x.Name == $"{name}'s Sandbox")).OwnerUserKey; + } + + private static LabUserProvisioningService Provisioning(IServiceProvider sp, LabDatabase db, UserKey owner) => + new(ActorProvider.For(owner), new(db), new(db), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), NullLogger.Instance); + + private static SandboxUserService UserService(IServiceProvider sp, LabDatabase db, UserKey owner, IUAuthClient client) => + new(new(db), client, db, new(db), Provisioning(sp, db, owner), + sp.GetRequiredService(), new(db, NullLogger.Instance), + sp.GetRequiredService(), sp.GetRequiredService(), + NullLogger.Instance); + + private static CreateUserRequest Request(string name) => new() { UserName = name, Email = $"{name}@example.test", Password = "test-password-8!" }; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Created_member_is_queryable_capacity_rejection_leaves_no_account_and_deletion_frees_capacity(bool selfDelete) + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var owner = await RegisterAsync(sp, db, "owner"); + var foreignOwner = await RegisterAsync(sp, db, "foreign"); + var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(foreignOwner, Request("foreign-member"), TestContext.Current.CancellationToken); + Assert.True(foreign.IsSuccess, foreign.FailureReason); + var foreignKey = UserKey.FromString(foreign.UserKey!); + + await using (var setup = db.CreateDbContext()) + { + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == owner, cancellationToken: TestContext.Current.CancellationToken); + settings.MaxUsers = 2; + await setup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var client = new Mock(MockBehavior.Strict); + var userClient = new Mock(MockBehavior.Strict); + client.SetupGet(x => x.Users).Returns(userClient.Object); + var service = UserService(sp, db, owner, client.Object); + + await service.CreateAsync(owner, Request("member"), TestContext.Current.CancellationToken); + var visible = await service.GetUsersAsync(owner, TestContext.Current.CancellationToken); + Assert.NotNull(visible); + var member = Assert.Single(visible.Users, x => !x.IsOwner).UserKey; + Assert.Equal(2, visible.Users.Count); + Assert.DoesNotContain(visible.Users, x => x.UserKey == foreignKey); + var beforeRejected = await db.SnapshotAsync(); + // The facade reports failures; assert persistence rather than dialog wording. + await service.CreateAsync(owner, Request("rejected-member"), TestContext.Current.CancellationToken); + Assert.Equal(beforeRejected, await db.SnapshotAsync()); + await using (var fresh = runtime.Services.CreateAsyncScope()) + { + var summaries = fresh.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var all = await summaries.QueryAsync(new UserQuery { PageSize = 100, IncludeDeleted = true }, TestContext.Current.CancellationToken); + Assert.DoesNotContain(all.Items, x => x.UserName == "rejected-member"); + } + Assert.False((await service.DeleteAsync(owner, foreignKey, TestContext.Current.CancellationToken)).Success); + Assert.Equal(beforeRejected, await db.SnapshotAsync()); + userClient.VerifyNoOtherCalls(); + var application = sp.GetRequiredService(); + + if (selfDelete) + { + userClient.Setup(x => x.DeleteMeAsync()).Returns(async () => + { + await application.DeleteMeAsync(Contexts.Create("users.delete.self", member, member)); + return new UAuthResult { IsSuccess = true }; + }); + + Assert.True(await new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance).DeleteMemberAsync(member, TestContext.Current.CancellationToken)); + userClient.Verify(x => x.DeleteMeAsync(), Times.Once); + } + else + { + userClient.Setup(x => x.DeleteUserAsync(member, It.IsAny())).Returns(async (UserKey target, DeleteUserRequest request) => + { + Assert.Equal(DeleteMode.Hard, request.Mode); + var result = await application.DeleteUserAsync(Contexts.Create("users.delete.admin", owner, target), request); + return new UAuthResult { IsSuccess = result.IsSuccess, Value = result }; + }); + Assert.True((await service.DeleteAsync(owner, member, TestContext.Current.CancellationToken)).Success); + userClient.Verify(x => x.DeleteUserAsync(member, It.IsAny()), Times.Once); + } + + await using var verification = runtime.Services.CreateAsyncScope(); + var lifecycleStore = verification.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, member), TestContext.Current.CancellationToken); + if (selfDelete) { Assert.NotNull(lifecycle); Assert.True(lifecycle.IsDeleted); } + else Assert.Null(lifecycle); + Assert.NotNull(await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, foreignKey), TestContext.Current.CancellationToken)); + var remaining = await service.GetUsersAsync(owner, TestContext.Current.CancellationToken); + Assert.NotNull(remaining); + Assert.Equal(owner, Assert.Single(remaining.Users).UserKey); + // Optional dashboard calls may fail; its persisted count must still be usable. + var dashboard = await new LabDashboardService(new(db), client.Object).GetAsync(owner, TestContext.Current.CancellationToken); + Assert.NotNull(dashboard); + Assert.Equal(1, dashboard.MemberCount); + Assert.Equal(1, dashboard.RemainingCapacity); + await service.CreateAsync(owner, Request("replacement"), TestContext.Current.CancellationToken); + Assert.Equal(2, (await service.GetUsersAsync(owner, TestContext.Current.CancellationToken))!.Users.Count); + await using var final = db.CreateDbContext(); + Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(2, await final.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == foreignOwner, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Owner_and_admin_recovery_restore_missing_membership_without_duplicate_or_foreign_changes(bool adminRepair) + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var owner = await RegisterAsync(sp, db, "repair-owner"); + var other = await RegisterAsync(sp, db, "other-owner"); + var member = await Provisioning(sp, db, owner).CreateMemberAsync(owner, Request("retained-member"), TestContext.Current.CancellationToken); + Assert.True(member.IsSuccess, member.FailureReason); + var roles = sp.GetRequiredService(); + var assignments = sp.GetRequiredService(); + await using (var setup = db.CreateDbContext()) + { + setup.SandboxMembers.Remove(await setup.SandboxMembers.SingleAsync(x => x.UserKey == owner, cancellationToken: TestContext.Current.CancellationToken)); + await setup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + if (adminRepair) + { + var context = Contexts.Create("identitylab.repair", LabAdminIdentity.UserKey); + var service = new LabRepairService(db, sp.GetRequiredService(), + roles, assignments, new LabAdminGuard(roles, assignments), NullLogger.Instance); + var snapshot = await service.GetSnapshotAsync(context, TestContext.Current.CancellationToken); + Assert.True(snapshot.UserScanComplete); + Assert.Contains(snapshot.OrphanUsers, x => x.UserKey == owner && x.OwnedSandboxId != null); + Assert.DoesNotContain(snapshot.OrphanUsers, x => x.UserKey == other); + Assert.True((await service.RepairOwnerAsync(context, owner, TestContext.Current.CancellationToken)).Success); + var repaired = await db.SnapshotAsync(); + Assert.False((await service.RepairOwnerAsync(context, owner, TestContext.Current.CancellationToken)).Success); + Assert.Equal(repaired, await db.SnapshotAsync()); + Assert.DoesNotContain((await service.GetSnapshotAsync(context, TestContext.Current.CancellationToken)).OrphanUsers, x => x.UserKey == owner); + } + else + { + var context = Contexts.Create("identitylab.repair", owner); + var service = new LabOwnerRepairService(db, sp.GetRequiredService(), + roles, assignments, NullLogger.Instance); + Assert.Equal(OwnerRepairState.CanRepair, (await service.GetStatusAsync(context, TestContext.Current.CancellationToken)).State); + Assert.True((await service.RepairAsync(context, TestContext.Current.CancellationToken)).Success); + var repaired = await db.SnapshotAsync(); + Assert.Equal(OwnerRepairState.NotRequired, (await service.GetStatusAsync(context, TestContext.Current.CancellationToken)).State); + Assert.False((await service.RepairAsync(context, TestContext.Current.CancellationToken)).Success); + Assert.Equal(repaired, await db.SnapshotAsync()); + } + await using var final = db.CreateDbContext(); + var members = await final.SandboxMembers.ToListAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(3, members.Count); + var restored = Assert.Single(members, x => x.UserKey == owner); + Assert.Equal(CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMemberRole.Owner, restored.Role); + Assert.Contains(members, x => x.UserKey.Value == member.UserKey && x.SandboxId == restored.SandboxId); + Assert.Contains(members, x => x.UserKey == other && x.SandboxId != restored.SandboxId); + Assert.Equal(2, await final.Sandboxes.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs index c1a93792..3bb5675f 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs @@ -31,7 +31,7 @@ public async Task Forged_actor_or_regular_member_cannot_provision(string request new(db), new(db), users.Object, roles.Object, assignments.Object, new(), NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - UserKey.FromString(requestedActor), new CreateUserRequest())); + UserKey.FromString(requestedActor), new CreateUserRequest(), TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); users.VerifyNoOtherCalls(); roles.VerifyNoOtherCalls(); @@ -83,14 +83,14 @@ public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_ var service = new LabUserProvisioningService(ActorProvider.For(LabDatabase.OwnerA), new(db), new(db), users.Object, roles.Object, assignments.Object, gate, NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - LabDatabase.OwnerA, new CreateUserRequest())); + LabDatabase.OwnerA, new CreateUserRequest(), TestContext.Current.CancellationToken)); Assert.NotNull(rollbackContext); Assert.Equal(createdKey, rollbackContext!.TargetUserKey); Assert.Equal(DeleteMode.Hard, rollbackMode); Assert.True(unrelatedDenied); Assert.True(rollbackAllowed); Assert.True(replayDenied); - Assert.True((await policy.DecideAsync(rollbackContext!)).IsDenied); + Assert.True((await policy.DecideAsync(rollbackContext!, TestContext.Current.CancellationToken)).IsDenied); users.Verify(x => x.DeleteUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); Assert.Equal(before, await db.SnapshotAsync()); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs index ff1c440e..1911204f 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs @@ -26,7 +26,7 @@ public async Task Client_values_cannot_forge_provisioning_or_rollback_tokens(obj UAuthActions.Users.DeleteAdmin }) { var result = await policy.DecideAsync(Contexts.Create(action, LabDatabase.OwnerA, - UserKey.FromString("unrelated"), attributes: attributes)); + UserKey.FromString("unrelated"), attributes: attributes), TestContext.Current.CancellationToken); Assert.True(result.IsDenied); } Assert.Equal(before, await db.SnapshotAsync()); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs index 38f108bf..507cbfab 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs @@ -43,10 +43,10 @@ public async Task Registration_propagates_trusted_context_and_persists_matching_ assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); var service = new LabRegistrationService(ActorProvider.For(null), users.Object, roles.Object, assignments.Object, new(db), new(db, NullLogger.Instance)); - var result = await service.RegisterAsync(new LabRegistrationRequest(" alice ", "alice@example.test", "test-password")); + var result = await service.RegisterAsync(new LabRegistrationRequest(" alice ", "alice@example.test", "test-password"), TestContext.Current.CancellationToken); Assert.True(result.IsSuccess); await using var verify = db.CreateDbContext(); - var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(); + var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(LabDatabase.OwnerA, sandbox.OwnerUserKey); Assert.Equal(LabDatabase.OwnerA, Assert.Single(sandbox.Members).UserKey); userStore.Verify(x => x.AssignAsync(LabDatabase.OwnerA, LabRoleIds.Owner, diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md index c5afa29d..2b424ee9 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md @@ -1,12 +1,14 @@ # Identity Lab security suite -Requires .NET 10 SDK and Docker with permission to start containers. Run from the repository root: +The security and integration tests require .NET 10 SDK and Docker with permission to start containers. Run from the repository root: ```sh -dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx +dotnet test --project tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj -c Release --report-trx ``` -By default, each database test class starts PostgreSQL 17 through Testcontainers. Every test gets a new database, applies the actual LabDbContext migrations, and drops its database on disposal. Tests never use EF InMemory. Nothing connects to application production databases. +The repository selects Microsoft.Testing.Platform in `global.json`. Use `--report-trx` for TRX output and `--filter-trait "Category=Render"` for trait filtering. CI collects coverage with `coverlet.MTP`; the Identity Lab job preserves the assembly include and migration-file exclusion through `--coverlet-include` and `--coverlet-exclude-by-file`. + +By default, each database test class starts PostgreSQL 17 through Testcontainers. Every test gets a new database, applies the actual LabDbContext migrations, and drops its database on disposal. Security and integration tests never use EF InMemory. Nothing connects to application production databases. Alternatively set `IDENTITYLAB_TEST_POSTGRES` to a connection string for a **dedicated test PostgreSQL instance**. Its login needs CREATE DATABASE and permission to drop the databases it creates. Only databases named `identitylab_test_` are created/dropped. This setting bypasses Docker; it does not bypass relational tests. Missing infrastructure fails fixture initialization rather than silently skipping tests. @@ -14,4 +16,18 @@ Tests replace authenticated-context sources and selected framework store/client `Regression/SystemRoleIdentityTests.cs` records an expected rejection that the inspected implementation does not enforce. It is intentionally neither skipped nor inverted to accept unsafe behavior. The `KnownDefect=SystemRoleIdentity` trait identifies it; normal local and CI runs include it. Expect that regression to fail until separately approved production work addresses the issue. Do not enable this suite as a required green check before defect triage. -See [coverage matrix](docs/security-coverage.md) and [execution report](docs/execution-report.md). The project is included in the solution. Repository CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 PostgreSQL job; the dedicated job uploads TRX results even on failure. +See [coverage matrix](docs/security-coverage.md) and [execution report](docs/execution-report.md). The project is included in the solution. Repository CI selects framework test projects separately and runs it in a dedicated .NET 10 PostgreSQL job; the dedicated job uploads TRX results even on failure. + +## Page render suite + +The `Rendering` tests use bUnit and need only the .NET 10 SDK; no Docker or PostgreSQL is required: + +```sh +dotnet test --project tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj -c Release --filter-trait "Category=Render" +``` + +The real pages and child components render with MudBlazor services and a popover provider. External client calls use explicit Moq responses. Dashboard initialization uses real Lab services with a uniquely named EF InMemory LabDbContext store; this verifies component initialization and navigation branches, not relational behavior. Existing service regression tests and their PostgreSQL fixtures are unchanged. Direct page rendering does not exercise router authorization or layouts. + +All 24 routable application page components have smoke test mappings (28 page-render cases and one route inventory guard). New pages need a real render test annotated with `[SmokeTestFor(typeof(PageComponent))]`; the route guard reports missing types and their route aliases. Existing behavioral render cases are preserved. No additional dependencies were introduced by the completion phase. + +See [page inventory and validation limits](docs/page-render-coverage.md). diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs index a6d147a6..c78ccaf4 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs @@ -30,7 +30,7 @@ public async Task Owner_name_with_admin_id_must_be_rejected_before_assignment() assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); var service = new LabRoleService(roles.Object, assignments.Object); await Assert.ThrowsAsync(() => service.AssignAsync( - TenantKeys.Single, LabDatabase.MemberA, "Owner")); + TenantKeys.Single, LabDatabase.MemberA, "Owner", TestContext.Current.CancellationToken)); userStore.Verify(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Admin, It.IsAny(), It.IsAny()), Times.Never); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs new file mode 100644 index 00000000..5f994ee9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs @@ -0,0 +1,135 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +internal sealed partial class PageRenderContext +{ + public static UserKey RenderUser => UserKey.FromString("render-user"); + + public void AddManagementServices() + { + AddLabServices(); + var summaries = new Mock(MockBehavior.Strict); + summaries.Setup(x => x.GetByKeysAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { new UserSummary + { + UserKey = RenderUser, DisplayName = "Render Person", UserName = "render-person", + PrimaryEmail = "render@example.test", Status = UserStatus.Active + } }); + var summaryFactory = new Mock(MockBehavior.Strict); + summaryFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(summaries.Object); + Services.AddSingleton(summaryFactory.Object); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + var product = new Mock(MockBehavior.Strict); + product.Setup(x => x.Get()).Returns(new UAuthProductInfo { Version = "render-test" }); + Services.AddSingleton(product.Object); + } + + public void SetupProfileAndAccess() + { + Users.Setup(x => x.GetMeAsync(It.IsAny())) + .ReturnsAsync(Success(new UserView + { + UserKey = RenderUser, DisplayName = "Render Person", UserName = "render-person", + PrimaryEmail = "render@example.test", Status = UserStatus.Active + })); + Identifiers.Setup(x => x.GetMyAsync(It.IsAny())) + .ReturnsAsync(Success(Empty())); + Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ReturnsAsync(Success(Empty())); + Authorization.Setup(x => x.GetMyRolesAsync(It.IsAny())) + .ReturnsAsync(Success(new UserRolesGetResult { UserKey = RenderUser, Roles = Empty() })); + Authorization.Setup(x => x.GetMyPermissionsAsync()) + .ReturnsAsync(Success(new UserPermissionsResult { Permissions = Array.Empty() })); + } + + public void AddAdminServices() + { + AddManagementServices(); + var role = Role.Create(LabRoleIds.Admin, TenantKeys.Single, "Ladmin", null, DateTimeOffset.UtcNow); + var roles = new Mock(MockBehavior.Strict); + roles.Setup(x => x.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { role }); + var roleFactory = new Mock(MockBehavior.Strict); + roleFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(roles.Object); + var assignments = new Mock(MockBehavior.Strict); + assignments.Setup(x => x.GetAssignmentsAsync(RenderUser, It.IsAny())) + .ReturnsAsync(new[] { new UserRole { Tenant = TenantKeys.Single, UserKey = RenderUser, RoleId = role.Id } }); + var assignmentFactory = new Mock(MockBehavior.Strict); + assignmentFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(assignments.Object); + Services.AddSingleton(roleFactory.Object); + Services.AddSingleton(assignmentFactory.Object); + Services.AddSingleton(Mock.Of()); + Services.AddScoped(); + Services.AddScoped(); + } + + public void AddDemoStoreServices() + { + AddLabServices(); + var flow = new Mock(MockBehavior.Strict); + flow.Setup(x => x.BeginAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Contexts.AuthenticatedFlow(RenderUser)); + var contexts = new Mock(MockBehavior.Strict); + contexts.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(Contexts.Create(LabDemoPermissions.ProductsRead, RenderUser)); + Services.AddSingleton(flow.Object); + Services.AddSingleton(contexts.Object); + // An active sandbox member without demo role assignments is a valid empty-access state. + var assignments = new Mock(MockBehavior.Strict); + assignments.Setup(x => x.GetAssignmentsAsync(RenderUser, It.IsAny())) + .ReturnsAsync(Array.Empty()); + var factory = new Mock(MockBehavior.Strict); + factory.Setup(x => x.Create(TenantKeys.Single)).Returns(assignments.Object); + Services.AddSingleton(factory.Object); + var roles = new Mock(MockBehavior.Strict); + roles.Setup(x => x.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + var roleFactory = new Mock(MockBehavior.Strict); + roleFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(roles.Object); + Services.AddSingleton(roleFactory.Object); + Services.AddScoped(); + } + + public void AddDiagnosticsServices() + { + Services.AddSingleton(new UAuthClientDiagnostics()); + Services.AddScoped(); + var chainId = SessionChainId.New(); + var sessionId = AuthSessionId.Parse(new string('s', 32)); + var now = DateTimeOffset.UtcNow; + var session = new SessionInfo(sessionId, now.AddMinutes(-1), now.AddHours(1), false); + var chain = new SessionChainSummary + { + ChainId = chainId, ActiveSessionId = sessionId, IsCurrentDevice = true, + State = SessionChainState.Active, CreatedAt = session.CreatedAt, LastSeenAt = now + }; + Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ReturnsAsync(Success(new PagedResult(new[] { chain }, 1, 1, 20, null, false))); + Sessions.Setup(x => x.GetMyChainDetailAsync(chainId)) + .ReturnsAsync(Success(new SessionChainDetail + { + ChainId = chainId, ActiveSessionId = sessionId, State = SessionChainState.Active, + CreatedAt = session.CreatedAt, LastSeenAt = now, Sessions = new[] { session } + })); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs new file mode 100644 index 00000000..0c59e9bf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs @@ -0,0 +1,144 @@ +using System.Reflection; +using System.Security.Claims; +using Bunit; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +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.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using MudBlazor; +using MudBlazor.Services; +using MudExtensions.Services; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +internal sealed partial class PageRenderContext : BunitContext +{ + public Mock Client { get; } = new(MockBehavior.Strict); + public Mock Sessions { get; } = new(MockBehavior.Strict); + public Mock Authorization { get; } = new(MockBehavior.Strict); + public Mock Identifiers { get; } = new(MockBehavior.Strict); + public Mock Users { get; } = new(MockBehavior.Strict); + + public PageRenderContext() + { + // MudBlazor's DOM measurement/focus calls have no browser in bUnit. + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddLogging(); + Services.AddMudServices(); + Services.AddMudExtensions(); + Services.AddAuthorizationCore(); + // Keep the real internal evaluator, without installing the production client host. + var registrations = new ServiceCollection(); + registrations.AddUltimateAuthClientBlazor(); + ((IServiceCollection)Services).Add(registrations.Single(x => x.ServiceType == typeof(IUAuthAuthorizationEvaluator))); + Client.SetupGet(x => x.Sessions).Returns(Sessions.Object); + Client.SetupGet(x => x.Authorization).Returns(Authorization.Object); + Client.SetupGet(x => x.Users).Returns(Users.Object); + Client.SetupGet(x => x.Identifiers).Returns(Identifiers.Object); + Services.AddSingleton(Client.Object); + Services.AddSingleton(Options.Create(new UAuthClientOptions())); + Services.AddSingleton(Options.Create(new UAuthServerOptions())); + var device = new Mock(); + device.Setup(x => x.GetOrCreateAsync(It.IsAny())) + .Returns(new ValueTask(DeviceId.Create("render-test-device-0001"))); + Services.AddSingleton(device.Object); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + } + + public IRenderedComponent RenderPage(UAuthState? state = null) where T : IComponent + { + // Register page-specific dependencies before the first render freezes DI. + Render(); + var authState = state ?? UAuthState.Anonymous(); + return Render>>(p => p + .Add(x => x.Value, Task.FromResult(new Microsoft.AspNetCore.Components.Authorization.AuthenticationState(authState.ToClaimsPrincipal()))) + .AddChildContent>(child => child + .Add(x => x.Value, authState) + .AddChildContent())).FindComponent(); + } + + public static UAuthState Authenticated(string role, params string[] permissions) + { + var state = UAuthState.Anonymous(); + // Use the actual snapshot lifecycle; no production friend assembly or API changes. + typeof(UAuthState).GetMethod("ApplySnapshot", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(state, [new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromString("render-user"), + Tenant = TenantKeys.Single, + SessionState = SessionState.Active, + UserStatus = UserStatus.Active + }, + Claims = ClaimsSnapshot.From(new[] { (Type: ClaimTypes.Role, Value: role) } + .Concat(permissions.Select(permission => (Type: "uauth:permission", Value: permission))).ToArray()) + }, DateTimeOffset.UtcNow]); + return state; + } + + public void AddLabServices() + { + // Only LabDbContext uses this isolated store. No server host, migrations or PostgreSQL. + var databaseName = Guid.NewGuid().ToString(); + Services.AddDbContextFactory(o => o.UseInMemoryDatabase(databaseName)); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddSingleton(Mock.Of()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + } + + public async Task SeedSandboxAsync(bool owner) + { + await using var db = await Services.GetRequiredService>().CreateDbContextAsync(); + var sandbox = new Sandbox + { + Name = "Render sandbox", + OwnerUserKey = UserKey.FromString(owner ? "render-user" : "other-owner"), + Settings = new SandboxSettings { MaxUsers = 5 } + }; + sandbox.Members.Add(new SandboxMember + { + UserKey = UserKey.FromString("render-user"), + Role = owner ? SandboxMemberRole.Owner : SandboxMemberRole.Member + }); + db.Sandboxes.Add(sandbox); + await db.SaveChangesAsync(); + } + + public static UAuthResult Success(T value) => new() { IsSuccess = true, Status = 200, Value = value }; + public static PagedResult Empty() => new(Array.Empty(), 0, 1, 20, null, false); +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderTests.cs new file mode 100644 index 00000000..0bf5feda --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderTests.cs @@ -0,0 +1,154 @@ +using Bunit; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dashboard; +using CodeBeam.UltimateAuth.Users.Contracts; +using Moq; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +[Trait("Category", "Render")] +public sealed class PageRenderTests +{ + [Fact] + [SmokeTestFor(typeof(LoginPage))] + public async Task Anonymous_login_renders_the_real_login_form() + { + await using var context = new PageRenderContext(); + var page = context.RenderPage(); + + page.WaitForAssertion(() => + { + Assert.Contains("Welcome back.", page.Find("h1").TextContent); + Assert.NotNull(page.FindComponent().Find("form")); + Assert.Contains("Email or Username", page.Markup); + }); + } + + [Fact] + public async Task Authenticated_login_renders_workspace_entry_instead_of_form() + { + await using var context = new PageRenderContext(); + var page = context.RenderPage(PageRenderContext.Authenticated("Owner")); + + page.WaitForAssertion(() => + { + Assert.Contains("Enter your workspace", page.Markup); + Assert.Empty(page.FindComponents()); + }); + } + + [Fact] + [SmokeTestFor(typeof(RegisterPage))] + public async Task Anonymous_registration_renders_without_an_existing_session() + { + await using var context = new PageRenderContext(); + context.AddLabServices(); + var page = context.RenderPage(); + + page.WaitForAssertion(() => + { + Assert.Equal("Create your sandbox.", page.Find("h1").TextContent.Trim()); + Assert.NotNull(page.FindComponent()); + Assert.NotEmpty(page.FindAll("input")); + }); + } + + [Fact] + [SmokeTestFor(typeof(LandingPage))] + public async Task Landing_page_renders_registration_entry() + { + await using var context = new PageRenderContext(); + var page = context.RenderPage(); + Assert.Contains(page.FindAll("a"), link => + link.GetAttribute("href") == "/register" && link.TextContent.Contains("Create Your Sandbox")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [SmokeTestFor(typeof(DashboardPage))] + public async Task Dashboard_renders_owner_and_member_navigation(bool owner) + { + await using var context = new PageRenderContext(); + context.AddLabServices(); + context.Users.Setup(x => x.GetMeAsync(It.IsAny())) + .ReturnsAsync(PageRenderContext.Success(new UserView { DisplayName = "Render Person" })); + context.Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ReturnsAsync(PageRenderContext.Success(PageRenderContext.Empty())); + context.Authorization.Setup(x => x.GetMyRolesAsync(It.IsAny())) + .ReturnsAsync(PageRenderContext.Success(new UserRolesGetResult + { + UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString("render-user"), + Roles = PageRenderContext.Empty() + })); + await context.SeedSandboxAsync(owner); + + var page = context.RenderPage(PageRenderContext.Authenticated(owner ? "Owner" : "User")); + page.WaitForAssertion(() => + { + Assert.Contains("Your identity workspace.", page.Find("h1").TextContent); + Assert.Contains("Render sandbox", page.Markup); + Assert.Contains("Render Person", page.Markup); + Assert.Equal(owner, page.FindAll("a").Any(x => x.GetAttribute("href") == "/dashboard/users")); + Assert.Equal(owner, page.FindAll("a").Any(x => x.GetAttribute("href") == "/dashboard/settings")); + }); + context.Users.Verify(x => x.GetMeAsync(It.IsAny()), Times.Once); + context.Sessions.Verify(x => x.GetMyChainsAsync(It.IsAny()), Times.Once); + context.Authorization.Verify(x => x.GetMyRolesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [SmokeTestFor(typeof(MySessionsPage))] + public async Task Sessions_render_empty_service_result() + { + await using var context = new PageRenderContext(); + context.Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ReturnsAsync(PageRenderContext.Success(PageRenderContext.Empty())); + var page = context.RenderPage(PageRenderContext.Authenticated("User")); + + page.WaitForAssertion(() => Assert.Contains("No session chains were returned.", page.Markup)); + Assert.Equal("My Sessions", page.Find("h1").TextContent.Trim()); + context.Sessions.Verify(x => x.GetMyChainsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Sessions_render_loading_then_explicit_service_failure() + { + await using var context = new PageRenderContext(); + var response = new TaskCompletionSource>>( + TaskCreationOptions.RunContinuationsAsynchronously); + context.Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())).Returns(response.Task); + var page = context.RenderPage(PageRenderContext.Authenticated("User")); + Assert.NotEmpty(page.FindComponents()); + + response.SetResult(new UAuthResult> { IsSuccess = false, Status = 503 }); + page.WaitForAssertion(() => + { + Assert.Contains("Unable to load session chains.", page.Markup); + Assert.Empty(page.FindComponents()); + }); + } + + [Theory] + [InlineData(false, "No roles available.")] + [InlineData(true, "Authorization service is unavailable.")] + [SmokeTestFor(typeof(AuthorizationPage))] + public async Task Authorization_management_renders_empty_and_failure_states(bool failure, string expected) + { + await using var context = new PageRenderContext(); + var query = context.Authorization.Setup(x => x.QueryRolesAsync(It.IsAny())); + if (failure) + query.ThrowsAsync(new HttpRequestException("Simulated unavailable service")); + else + query.ReturnsAsync(PageRenderContext.Success(PageRenderContext.Empty())); + + var page = context.RenderPage(PageRenderContext.Authenticated("Owner")); + page.WaitForAssertion(() => Assert.Contains(expected, page.Markup)); + Assert.Equal("Authorization Management", page.Find("h1").TextContent.Trim()); + context.Authorization.Verify(x => x.QueryRolesAsync(It.IsAny()), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RemainingPageRenderTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RemainingPageRenderTests.cs new file mode 100644 index 00000000..fab7ee53 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RemainingPageRenderTests.cs @@ -0,0 +1,191 @@ +using Bunit; +using CodeBeam.UltimateAuth.Core.Defaults; +using AuthorizationLabPage = CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dashboard.AuthorizationLab; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.AuthorizationLab; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dashboard; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +[Trait("Category", "Render")] +public sealed class RemainingPageRenderTests +{ + [Fact, SmokeTestFor(typeof(UsersPage))] + public async Task Owner_can_render_sandbox_users() + { + await using var context = new PageRenderContext(); + context.AddManagementServices(); + await context.SeedSandboxAsync(owner: true); + var page = AssertPage(context, "Users", "Owner"); + page.WaitForAssertion(() => Assert.Contains("Render Person", page.Markup)); + } + + [Fact, SmokeTestFor(typeof(MyIdentityPage))] + public async Task User_can_render_profile_identifiers_and_credentials() + { + await using var context = new PageRenderContext(); + context.AddManagementServices(); + context.SetupProfileAndAccess(); + await context.SeedSandboxAsync(owner: false); + var page = AssertPage(context, "My Identity", "User"); + page.WaitForAssertion(() => + { + Assert.Contains("Render Person", page.Markup); + Assert.Contains(page.FindAll("h2"), heading => heading.TextContent == "Credentials & Security"); + }); + } + + [Fact, SmokeTestFor(typeof(MyAccessPage))] + public async Task User_can_render_access_with_no_assigned_roles_or_permissions() + { + await using var context = new PageRenderContext(); + context.AddManagementServices(); + context.SetupProfileAndAccess(); + await context.SeedSandboxAsync(owner: false); + var page = AssertPage(context, "My Access", "User"); + page.WaitForAssertion(() => + { + Assert.Contains("No roles assigned", page.Markup); + Assert.Contains("No permissions assigned", page.Markup); + Assert.DoesNotContain("Unable to load sandbox members.", page.Markup); + }); + } + + [Fact, SmokeTestFor(typeof(SettingsPage))] + public async Task Owner_can_render_sandbox_settings() + { + await using var context = new PageRenderContext(); + context.AddManagementServices(); + await context.SeedSandboxAsync(owner: true); + var page = AssertPage(context, "Sandbox Settings", "Owner"); + page.WaitForAssertion(() => Assert.Contains("Render Person", page.Markup)); + } + + [Fact, SmokeTestFor(typeof(ActivityPage))] + public async Task User_can_render_empty_sandbox_activity() + { + await using var context = new PageRenderContext(); + context.AddLabServices(); + await context.SeedSandboxAsync(owner: false); + var page = AssertPage(context, "Activity", "User"); + page.WaitForAssertion(() => Assert.Contains("No activity recorded", page.Markup)); + } + + [Fact, SmokeTestFor(typeof(AdminPage))] + public async Task Ladmin_can_render_administration_and_repair_panel() + { + await using var context = new PageRenderContext(); + context.AddAdminServices(); + var page = AssertPage(context, "Sandbox Management", "Ladmin"); + page.WaitForAssertion(() => + { + Assert.NotNull(page.FindComponent()); + Assert.Contains("No sandboxes found.", page.Markup); + }); + } + + [Fact, SmokeTestFor(typeof(SessionDiagnostics))] + public async Task User_can_render_current_session_diagnostics() + { + await using var context = new PageRenderContext(); + context.AddDiagnosticsServices(); + var page = AssertPage(context, "Session Diagnostics", "User"); + page.WaitForAssertion(() => Assert.Contains("Session active", page.Markup)); + } + + [Fact, SmokeTestFor(typeof(DemoStorePage))] + public async Task User_can_render_store_without_demo_role_grants() + { + await using var context = new PageRenderContext(); + context.AddDemoStoreServices(); + await context.SeedSandboxAsync(owner: false); + var page = AssertPage(context, "Demo Store", "User"); + page.WaitForAssertion(() => Assert.Contains("Your Demo Access", page.Markup)); + } + + [Fact, SmokeTestFor(typeof(AuthorizationLabPage))] + public async Task User_can_render_authorization_lab_index() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Authorization Lab", "User"); + } + + [Fact, SmokeTestFor(typeof(Error))] + public async Task Error_page_renders_without_a_request_context() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Error."); + } + + [Fact, SmokeTestFor(typeof(PublicPage))] + public async Task Public_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Public Access"); + } + + [Fact, SmokeTestFor(typeof(AuthenticatedPage))] + public async Task Authenticated_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Authenticated Page", "User"); + } + + [Fact, SmokeTestFor(typeof(AdminRolePage))] + public async Task Admin_role_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Admin Role", "Admin"); + } + + [Fact, SmokeTestFor(typeof(AdminPermissionPage))] + public async Task Admin_permission_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Admin Permission", "Admin", adminPermission: true); + } + + [Fact, SmokeTestFor(typeof(AspNetAuthorizePage))] + public async Task Aspnet_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "ASP.NET Core Authorize", "User"); + } + + [Fact, SmokeTestFor(typeof(CombinedAuthorizationPage))] + public async Task Combined_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Combined Authorization", "Admin", adminPermission: true); + } + + [Fact, SmokeTestFor(typeof(StateViewPage))] + public async Task State_view_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "UAuthStateView", "Admin", authorizationExamples: true); + } + + [Fact, SmokeTestFor(typeof(MatchModesPage))] + public async Task Match_modes_authorization_sample_renders() + { + await using var context = new PageRenderContext(); + AssertPage(context, "Authorization Match Modes", "Admin", authorizationExamples: true); + } + + private static IRenderedComponent AssertPage(PageRenderContext context, string expected, string? role = null, bool authorizationExamples = false, bool adminPermission = false) where T : IComponent + { + var page = context.RenderPage(role is null ? null : PageRenderContext.Authenticated(role, adminPermission ? new[] { UAuthActions.Users.QueryAdmin } : Array.Empty())); + page.WaitForAssertion(() => + { + Assert.Contains(expected, page.Markup); + // A heading must not mask initialization falling into an error fallback. + // These two sample pages deliberately show denied authorization examples. + if (!authorizationExamples) + Assert.Empty(page.FindAll(".mud-alert-error")); + }); + return page; + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RouteSmokeCoverageTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RouteSmokeCoverageTests.cs new file mode 100644 index 00000000..981cd2fa --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/RouteSmokeCoverageTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +/// Connects a page to the executable render test that exercises it. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +internal sealed class SmokeTestForAttribute(Type componentType) : Attribute +{ + public Type ComponentType { get; } = componentType; +} + +[Trait("Category", "Render")] +public sealed class RouteSmokeCoverageTests +{ + [Fact] + public void Every_identity_lab_route_component_has_an_executable_smoke_test() + { + // Inspect the application assembly only. External/shared components and the + // test assembly cannot become route candidates. A multi-route page is one + // component; include all its aliases in any failure message. + var pages = typeof(LoginPage).Assembly.GetTypes() + .Where(type => !type.IsAbstract && typeof(IComponent).IsAssignableFrom(type)) + .Select(type => new + { + Type = type, + Routes = type.GetCustomAttributes(inherit: false) + .Select(route => route.Template).OrderBy(route => route).ToArray() + }) + .Where(page => page.Routes.Length > 0) + .ToDictionary(page => page.Type, page => page.Routes); + Assert.NotEmpty(pages); + + var mappings = typeof(PageRenderTests).Assembly.GetTypes() + .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + .SelectMany(method => method.GetCustomAttributes() + .Select(attribute => (Method: method, Page: attribute.ComponentType))) + .ToArray(); + Assert.NotEmpty(mappings); + foreach (var mapping in mappings) + { + var fact = mapping.Method.GetCustomAttribute(); // Includes TheoryAttribute. + Assert.True(fact is not null && fact.Skip is null, + $"{mapping.Method.DeclaringType?.Name}.{mapping.Method.Name} must be an enabled xUnit render test."); + Assert.True(pages.ContainsKey(mapping.Page), + $"Smoke mapping references a non-routable Identity Lab component: {mapping.Page.FullName}."); + } + + var tested = mappings.Select(mapping => mapping.Page).ToHashSet(); + var missing = pages.Where(page => !tested.Contains(page.Key)) + .OrderBy(page => page.Key.FullName) + .Select(page => $"{page.Key.FullName}: {string.Join(", ", page.Value)}") + .ToArray(); + Assert.True(missing.Length == 0, + "Identity Lab pages missing render smoke tests:\n" + string.Join("\n", missing) + + "\nAdd a real render test and annotate it with [SmokeTestFor(typeof(PageComponent))]."); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs index fd2fe01d..270db14e 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs @@ -26,7 +26,7 @@ public async Task Admin_guard_uses_store_assignment_even_when_attributes_claim_l users.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); var context = Contexts.Create("identitylab.admin", LabDatabase.OwnerA, attributes: new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); - Assert.Equal(assigned, await new LabAdminGuard(roles.Object, users.Object).IsAdminAsync(context)); + Assert.Equal(assigned, await new LabAdminGuard(roles.Object, users.Object).IsAdminAsync(context, TestContext.Current.CancellationToken)); } [Theory] @@ -39,7 +39,7 @@ public async Task Provisioning_role_helper_rejects_roles_outside_owner_and_user( var roles = new Mock(MockBehavior.Strict); var users = new Mock(MockBehavior.Strict); await Assert.ThrowsAsync(() => new CodeBeam.UltimateAuth.IdentityLab.Services.LabRoleService( - roles.Object, users.Object).AssignAsync(TenantKeys.Single, LabDatabase.MemberA, name)); + roles.Object, users.Object).AssignAsync(TenantKeys.Single, LabDatabase.MemberA, name, TestContext.Current.CancellationToken)); roles.VerifyNoOtherCalls(); users.VerifyNoOtherCalls(); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs index 4bef7f72..865b7ca9 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs @@ -15,7 +15,7 @@ public async Task Public_role_assignment_is_denied_even_when_context_claims_admi var context = Contexts.Create(UAuthActions.Authorization.Roles.AssignAdmin, CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(actor), LabDatabase.MemberA, attributes: new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); - var result = await new LabApplicationPolicy().DecideAsync(context); + var result = await new LabApplicationPolicy().DecideAsync(context, TestContext.Current.CancellationToken); Assert.True(result.IsDenied); Assert.Equal("lab_role_management_restricted", result.DenyReason); } @@ -25,11 +25,11 @@ public async Task Trusted_system_role_assignment_and_authenticated_read_remain_a { var policy = new LabApplicationPolicy(); Assert.True((await policy.DecideAsync(Contexts.Create( - UAuthActions.Authorization.Roles.AssignAdmin, system: true))).IsAllowed); + UAuthActions.Authorization.Roles.AssignAdmin, system: true), TestContext.Current.CancellationToken)).IsAllowed); Assert.True((await policy.DecideAsync(Contexts.Create( - UAuthActions.Authorization.Roles.GetSelf, LabDatabase.MemberA))).IsAllowed); + UAuthActions.Authorization.Roles.GetSelf, LabDatabase.MemberA), TestContext.Current.CancellationToken)).IsAllowed); Assert.True((await policy.DecideAsync(Contexts.Create( - UAuthActions.Authorization.Roles.GetSelf))).IsDenied); + UAuthActions.Authorization.Roles.GetSelf), TestContext.Current.CancellationToken)).IsDenied); } [Theory] diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs index dd0e29a3..0d3d99ee 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs @@ -22,11 +22,11 @@ public async Task Membership_is_removed_only_after_framework_self_deletion_succe var client = new Mock(MockBehavior.Strict); client.SetupGet(x => x.Users).Returns(users.Object); var service = new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance); - Assert.Equal(success, await service.DeleteMemberAsync(LabDatabase.MemberA)); + Assert.Equal(success, await service.DeleteMemberAsync(LabDatabase.MemberA, TestContext.Current.CancellationToken)); await using var verify = db.CreateDbContext(); - Assert.Equal(!success, await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA)); - Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB)); - Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.OwnerA)); + Assert.Equal(!success, await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA, cancellationToken: TestContext.Current.CancellationToken)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB, cancellationToken: TestContext.Current.CancellationToken)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.OwnerA, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -37,7 +37,7 @@ public async Task Owner_self_deletion_never_calls_framework_client() var before = await db.SnapshotAsync(); var client = new Mock(MockBehavior.Strict); var service = new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance); - Assert.False(await service.DeleteMemberAsync(LabDatabase.OwnerA)); + Assert.False(await service.DeleteMemberAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken)); client.VerifyNoOtherCalls(); Assert.Equal(before, await db.SnapshotAsync()); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs new file mode 100644 index 00000000..7a698f9b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs @@ -0,0 +1,78 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class DashboardTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData(1, 1, 0)] + [InlineData(5, 5, 3)] + [InlineData(100, SandboxLimits.AbsoluteMaxUsers, 18)] + public async Task Overview_uses_own_membership_and_effective_capacity_when_optional_services_fail( + int configuredLimit, int expectedLimit, int expectedRemaining) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + await using (var setup = db.CreateDbContext()) + { + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA, cancellationToken: TestContext.Current.CancellationToken); + settings.MaxUsers = configuredLimit; + await setup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + var users = new Mock(MockBehavior.Strict); + users.Setup(x => x.GetMeAsync(null)).ThrowsAsync(new InvalidOperationException("Profile unavailable")); + var sessions = new Mock(MockBehavior.Strict); + sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Sessions unavailable")); + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(x => x.GetMyRolesAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Roles unavailable")); + var client = new Mock(MockBehavior.Strict); + client.SetupGet(x => x.Users).Returns(users.Object); + client.SetupGet(x => x.Sessions).Returns(sessions.Object); + client.SetupGet(x => x.Authorization).Returns(authorization.Object); + var service = new LabDashboardService(new(db), client.Object); + + var owner = await service.GetAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken); + Assert.NotNull(owner); + Assert.Equal("Sandbox A", owner.SandboxName); + Assert.True(owner.IsOwner); + Assert.Equal(2, owner.MemberCount); + Assert.Equal(expectedLimit, owner.MaxUsers); + Assert.Equal(expectedRemaining, owner.RemainingCapacity); + Assert.Null(owner.DisplayName); + Assert.Null(owner.ChainCount); + Assert.Null(owner.RoleCount); + users.VerifyAll(); + sessions.VerifyAll(); + authorization.VerifyAll(); + + var member = await service.GetAsync(LabDatabase.MemberA, TestContext.Current.CancellationToken); + Assert.NotNull(member); + Assert.Equal(owner.SandboxId, member.SandboxId); + Assert.False(member.IsOwner); + Assert.Equal(owner.MemberCount, member.MemberCount); + + Assert.True(await new SandboxService(db).RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA, TestContext.Current.CancellationToken)); + var after = await service.GetAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken); + Assert.NotNull(after); + Assert.Equal(1, after.MemberCount); + Assert.Equal(expectedLimit - 1, after.RemainingCapacity); + } + + [Fact] + public async Task Missing_membership_returns_no_dashboard_without_requesting_optional_data() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var client = new Mock(MockBehavior.Strict); + Assert.Null(await new LabDashboardService(new(db), client.Object).GetAsync(UserKey.FromString("orphan"), TestContext.Current.CancellationToken)); + client.VerifyNoOtherCalls(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs index 5077f1a9..bb5ec8fa 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs @@ -26,7 +26,7 @@ public async Task Demo_operations_reject_system_and_forged_names_without_accessi var roles = new Mock(MockBehavior.Strict); var assignments = new Mock(MockBehavior.Strict); var service = new LabDemoRoleService(ActorProvider.For(LabDatabase.OwnerA), db, roles.Object, assignments.Object); - await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, role, true)); + await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, role, true, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); roles.VerifyNoOtherCalls(); assignments.VerifyNoOtherCalls(); @@ -48,7 +48,7 @@ public async Task Assignment_requires_active_persisted_owner_and_same_sandbox_re var roles = new Mock(MockBehavior.Strict); var assignments = new Mock(MockBehavior.Strict); var service = new LabDemoRoleService(ActorProvider.For(UserKey.FromString(actor)), db, roles.Object, assignments.Object); - await Assert.ThrowsAsync(() => service.SetRoleAsync(UserKey.FromString(target), LabRoles.Manager, true)); + await Assert.ThrowsAsync(() => service.SetRoleAsync(UserKey.FromString(target), LabRoles.Manager, true, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); roles.VerifyNoOtherCalls(); assignments.VerifyNoOtherCalls(); @@ -80,12 +80,12 @@ public async Task Demo_role_id_must_match_fixed_identity_before_any_assignment(b var service = new LabDemoRoleService(ActorProvider.For(LabDatabase.OwnerA), db, roles.Object, assignments.Object); if (forged) { - await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, "Manager", true)); + await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken)); userStore.VerifyNoOtherCalls(); } else { - await service.SetRoleAsync(LabDatabase.MemberA, "Manager", true); + await service.SetRoleAsync(LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken); userStore.Verify(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Manager, It.IsAny(), It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs index f3ada7e1..1800303b 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs @@ -37,11 +37,11 @@ public async Task Demo_store_only_accepts_exact_demo_permissions_from_fixed_role var roles = new Mock(); roles.Setup(x => x.Create(It.IsAny())).Returns(store.Object); var service = new LabDemoStoreService(ActorProvider.For(LabDatabase.MemberA), db, roles.Object, users.Object); - var actual = await service.GetPermissionsAsync(); + var actual = await service.GetPermissionsAsync(TestContext.Current.CancellationToken); Assert.True(actual.SetEquals(expected)); - await service.RequireAsync(LabDemoPermissions.ProductsRead); - await Assert.ThrowsAsync(() => service.RequireAsync("users.delete.admin")); + await service.RequireAsync(LabDemoPermissions.ProductsRead, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => service.RequireAsync("users.delete.admin", TestContext.Current.CancellationToken)); await db.SetStatusAsync(SandboxStatus.Suspended); - Assert.Empty(await service.GetPermissionsAsync()); + Assert.Empty(await service.GetPermissionsAsync(TestContext.Current.CancellationToken)); } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs index a55933f6..df6481e0 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs @@ -20,14 +20,45 @@ public async Task Repair_removes_only_own_stale_members_and_preserves_both_owner var stores = new Mock(); stores.Setup(x => x.Create(It.IsAny())).Returns(store.Object); var service = new SandboxRepairService(db, stores.Object, NullLogger.Instance); - await Assert.ThrowsAsync(() => service.RepairAsync(LabDatabase.MemberA)); - var result = await service.RepairAsync(LabDatabase.OwnerA); + await Assert.ThrowsAsync(() => service.RepairAsync(LabDatabase.MemberA, TestContext.Current.CancellationToken)); + var result = await service.RepairAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken); Assert.Equal(1, result.RemovedMemberships); await using var verify = db.CreateDbContext(); - var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync(); + var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.DoesNotContain(LabDatabase.MemberA, keys); Assert.Contains(LabDatabase.MemberB, keys); Assert.Contains(LabDatabase.OwnerA, keys); Assert.Contains(LabDatabase.OwnerB, keys); } + [Fact] + public async Task Preview_matches_repair_and_repeated_repair_preserves_valid_and_unrelated_members() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var validKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString("valid-member"); + await new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, validKey, TestContext.Current.CancellationToken); + var store = new Mock(MockBehavior.Strict); + store.Setup(x => x.GetByKeysAsync( + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { new UserSummary { UserKey = validKey } }); + var stores = new Mock(); + stores.Setup(x => x.Create(It.IsAny())).Returns(store.Object); + var service = new SandboxRepairService(db, stores.Object, NullLogger.Instance); + var before = await db.SnapshotAsync(); + Assert.Equal(new SandboxRepairPreview(3, 1), await service.PreviewAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken)); + Assert.Equal(before, await db.SnapshotAsync()); + Assert.Equal(1, (await service.RepairAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken)).RemovedMemberships); + var repaired = await db.SnapshotAsync(); + Assert.Equal(new SandboxRepairPreview(2, 0), await service.PreviewAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken)); + Assert.Equal(0, (await service.RepairAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken)).RemovedMemberships); + Assert.Equal(repaired, await db.SnapshotAsync()); + await using var verify = db.CreateDbContext(); + var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(4, keys.Count); + Assert.Contains(validKey, keys); + Assert.Contains(LabDatabase.OwnerA, keys); + Assert.Contains(LabDatabase.OwnerB, keys); + Assert.Contains(LabDatabase.MemberB, keys); + Assert.DoesNotContain(LabDatabase.MemberA, keys); + } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs new file mode 100644 index 00000000..e631298c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs @@ -0,0 +1,80 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class SandboxAdminTests(PostgresFixture postgres) : IClassFixture +{ + private static readonly AccessContext Admin = Contexts.Create("identitylab.tests", UserKey.FromString("admin")); + + private static SandboxAdminService Create(LabDatabase db) + { + // Guard authorization is covered separately; these tests exercise persisted admin workflows. + var guard = new Mock(MockBehavior.Strict); + guard.Setup(x => x.EnsureAdminAsync(Admin, It.IsAny())).Returns(Task.CompletedTask); + return new(db, guard.Object); + } + + [Fact] + public async Task Capacity_changes_respect_existing_members_and_control_subsequent_additions() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandboxes = new SandboxService(db); + var sandbox = (await sandboxes.GetByUserAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken))!; + var service = Create(db); + var before = await db.SnapshotAsync(); + await Assert.ThrowsAsync(() => service.SetMaxUsersAsync(Admin, sandbox.Id, 1, TestContext.Current.CancellationToken)); + Assert.Equal(before, await db.SnapshotAsync()); + Assert.True(await service.SetMaxUsersAsync(Admin, sandbox.Id, 2, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => sandboxes.AddMemberAsync(LabDatabase.OwnerA, UserKey.FromString("new-member"), TestContext.Current.CancellationToken)); + Assert.True(await service.SetMaxUsersAsync(Admin, sandbox.Id, 3, TestContext.Current.CancellationToken)); + await sandboxes.AddMemberAsync(LabDatabase.OwnerA, UserKey.FromString("new-member"), TestContext.Current.CancellationToken); + var summary = await service.GetAsync(Admin, sandbox.Id, TestContext.Current.CancellationToken); + Assert.NotNull(summary); + Assert.Equal(3, summary.MemberCount); + Assert.Equal(3, summary.MaxUsers); + Assert.Equal(2, (await sandboxes.GetMembersAsync((await sandboxes.GetByUserAsync(LabDatabase.OwnerB, TestContext.Current.CancellationToken))!.Id, TestContext.Current.CancellationToken)).Count); + } + + [Theory] + [InlineData("%")] + [InlineData("_")] + [InlineData("\\")] + public async Task Search_treats_postgres_pattern_characters_literally(string literal) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandbox = (await new SandboxService(db).GetByUserAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken))!; + var service = Create(db); + Assert.True(await service.RenameAsync(Admin, sandbox.Id, $" Lab {literal} special ", TestContext.Current.CancellationToken)); + var result = await service.QueryAsync(Admin, new SandboxQuery { Search = $" {literal} " }, TestContext.Current.CancellationToken); + Assert.Equal(1, result.TotalCount); + Assert.Equal(sandbox.Id, Assert.Single(result.Items).Id); + Assert.Equal($"Lab {literal} special", result.Items[0].Name); + } + + [Fact] + public async Task Statistics_follow_status_transitions_and_membership_removal() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandboxes = new SandboxService(db); + var a = (await sandboxes.GetByUserAsync(LabDatabase.OwnerA, TestContext.Current.CancellationToken))!; + var b = (await sandboxes.GetByUserAsync(LabDatabase.OwnerB, TestContext.Current.CancellationToken))!; + var service = Create(db); + Assert.Equal(new SandboxStatistics(2, 2, 0, 0, 4), await service.GetStatisticsAsync(Admin, TestContext.Current.CancellationToken)); + Assert.True(await sandboxes.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA, TestContext.Current.CancellationToken)); + Assert.True(await service.SetStatusAsync(Admin, a.Id, SandboxStatus.Passive, TestContext.Current.CancellationToken)); + Assert.True(await service.SetStatusAsync(Admin, b.Id, SandboxStatus.Suspended, TestContext.Current.CancellationToken)); + Assert.Equal(new SandboxStatistics(2, 0, 1, 1, 3), await service.GetStatisticsAsync(Admin, TestContext.Current.CancellationToken)); + var suspended = await service.QueryAsync(Admin, new SandboxQuery { Status = SandboxStatus.Suspended }, TestContext.Current.CancellationToken); + Assert.Equal(1, suspended.TotalCount); + Assert.Equal(b.Id, Assert.Single(suspended.Items).Id); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md deleted file mode 100644 index 2faaffd1..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md +++ /dev/null @@ -1,48 +0,0 @@ -# Execution report β€” 2026-10-30 - -Branch: `test/identity-lab-security`, based on `dev` at `64d1dc5` (Identity Lab: UI Polish, Authorization Playground & Diagnostics, #118). Remote `dev` was fetched and confirmed to match the supplied checkout. No production source was changed. No merge or push was performed. - -## Implemented and executed - -.NET SDK 10.0.403; Release configuration; PostgreSQL 17 (Testcontainers 4.16.0); actual Lab and framework migrations. Every relational test uses an isolated database. The real integration runtime composes framework services, both PostgreSQL contexts, seed contributors, and Identity Lab policies; focused tests mock selected stores/clients and authenticated context sources. - -| Identity Lab area | Passed | Failed | Skipped | -|---|---:|---:|---:| -| Security: admin guard, role policies, root resolution | 15 | 0 | 0 | -| Authorization: two-sandbox targets, status, owner restrictions | 10 | 0 | 0 | -| Services: demo roles/store, repair, account deletion | 20 | 0 | 0 | -| Provisioning: forged actors/tokens, rollback replay, registration | 7 | 0 | 0 | -| Integration: real registration/seeding/provisioning, context attribute forgery, role denials, fault cleanup, relational invariants | 14 | 0 | 0 | -| Regression: mismatched system role identity | 0 | 1 | 0 | -| **Total** | **66** | **1** | **0** | - -The enabled failing regression expresses required rejection. It was not skipped, inverted or excluded from CI. Final Identity Lab execution took 33 seconds after build. All 67 discovered cases executed. - -Existing EF integration tests: **3 passed, 0 failed, 0 skipped**. Existing unit tests: **2,754 passed, 0 failed, 0 skipped**. Existing broader integration tests: **375 passed, 0 failed, 0 skipped** (4 minutes 44 seconds). Across existing projects: **3,132 passed, 0 failed, 0 skipped**. - -## Confirmed integrity defect: system-role ID validation gap - -Runtime reproduction is encoded in `Regression/SystemRoleIdentityTests.cs`: - -1. Configure the role-store contract to resolve normalized `OWNER` to a Role whose ID is `LabRoleIds.Admin`. -2. Return no assignments for a regular target user. -3. Invoke `LabRoleService.AssignAsync(singleTenant, target, "Owner")`. -4. The implementation calls AssignAsync with the returned Admin ID. The regression requires InvalidOperationException before assignment and fails because no exception is thrown. - -This requires a malformed role definition or compromised store; no public operation that creates that definition was established. Admin seed validation normally rejects mismatched system IDs. This is a reproduced defense-in-depth integrity defect, **not a demonstrated ordinary-user escalation**. The helper is not registered as a public role-mutation endpoint. Production code remains unchanged. - -The real-persistence tests separately confirm that ordinary role-service attempts to acquire Owner/Ladmin or mutate the admin role are denied without altering stored role state. The real context factory replaces forged typed Ladmin attributes with stored Owner role IDs, and the sandbox policy still denies the foreign target. Legitimate registration creates Owner rather than Ladmin, trusted seeding assigns Ladmin only to the fixed administrator, and member provisioning/demo assignment preserves regular user boundaries. - -Other risks, not confirmed exploits: registration permits partial account creation and returns RequiresRepair after some failures; multiple services trust actor-key arguments supplied by their callers. Authenticated transport tests are required before classifying those as exploitable defects. - -## Infrastructure and verification - -Initial shell attempts could not find dotnet or reach the proxy/Docker socket. Enabling network access for the shell command made the configured infrastructure accessible; a local .NET 10 SDK was installed under work/. These initial limitations were resolved. No relational tests were substituted or disabled. The test project explicitly pins EF Core 10.0.12 to match the application and avoid transitive 10.0.5 assembly conflicts. Testcontainers was updated to 4.16.0 after restore reported advisories in an older transitive SSH.NET dependency. - -`git diff --check` passes. Application builds emit an existing MudBlazor analyzer warning about Disabled on MudSelectItemGroupExtended; production code was left unchanged. CI itself has not been run remotely, and the external-PostgreSQL connection-string alternative was not separately exercised. - -## CI readiness and remaining boundaries - -The project is in UltimateAuth.slnx. Existing CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 job using Docker/Testcontainers, with TRX uploaded even on failure. No continue-on-error or defect exclusion was added. - -**Not ready to require as a green CI gate:** the preserved integrity regression fails and needs separate triage/approved production work. Full hosted HTTP/cookie/context authenticity, diagnostics transport, standalone identifier availability, complete deletion cleanup, owner/admin repair, registration fault recovery, and cancellation/restart boundaries remain partial or untested. See the coverage matrix for exact boundaries. Passing the other tests is not a claim of complete security coverage. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md deleted file mode 100644 index 9f6f5f98..00000000 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md +++ /dev/null @@ -1,48 +0,0 @@ -# Inspection and security coverage matrix - -## Architecture and trust boundaries - -Identity Lab is a .NET 10 interactive Blazor Server application. Framework entities use UAuthDbContext; sandbox, membership, settings and activity entities use LabDbContext, on PostgreSQL. These are separate persistence units. Registration is a multi-step orchestration, not one transaction across both contexts. - -`SandboxUAuthAccessPolicy` constrains framework users, credentials, sessions, roles and flows. It queries membership/status and validates single and batch targets. `LabApplicationPolicy` blocks public role mutation even for Ladmin. Policies compose with framework policies: an Allow from one policy is not evidence that the entire operation succeeds. - -`LabDemoRoleService` resolves authenticated context internally, requires persisted active ownership, restricts targets to regular members in the same sandbox, and pins Manager/Customer IDs. `LabUserProvisioningService` additionally compares its actor argument to the authenticated actor. Internal reference tokens protect account creation and narrowly scoped rollback deletion. `LabRoleService` is a trusted store helper; it is not registered as a public mutation endpoint. - -`LabAdminGuard` rechecks fixed-ID roles and assignments in stores. `/api/lab/admin` applies an authorization filter and its services also use the guard. `LabRootResolver`, unlike the guard, consumes typed role IDs from server-generated context attributes. Context production and transport tamper resistance therefore need separate integration coverage. - -`SandboxService`, repair services and some other application methods accept user keys as trusted caller inputs. Direct invocation tests establish their database behavior; they do not establish that every Blazor or HTTP caller supplies the authenticated identity. UI visibility was not counted as authorization. - -Admin seeding verifies fixed system role IDs, prevents identifier takeover and assigns Ladmin to the configured fixed identity. Demo seeding pins IDs and reconciles permission sets. The database has unique owner and membership indexes, a filtered unique owner-membership index, foreign keys and cascading membership/settings deletion. Membership capacity changes use serializable transactions. - -Existing tests use xUnit 2.9.3, Test SDK 17.14.1 and net10.0 for EF integration; those tests use SQLite. This project follows their xUnit/SDK conventions but uses PostgreSQL for provider-specific constraints, migrations, scope queries and transaction behavior. It adds no duplicate framework CRUD test suite. - -## Matrix - -The implemented tests have been compiled and exercised against PostgreSQL 17. The execution report records final counts and the deliberately preserved failing regression. - -| Priority | Boundary | Test area | Status and limits | -|---|---|---|---| -| P0 | Public Owner/User/Ladmin role mutation | Security/RolePolicyTests | Implemented policy and actual framework role-service denials, stored assignments unchanged, authenticated reads and system positive control; no HTTP dispatch | -| P0 | Demo operations cannot assign system roles | Services/DemoRoleServiceTests | Implemented hostile names, owner/member scope, inactive status, wrong fixed ID, legitimate assignment; focused role stores mocked; full-persistence integration also covers legitimate Manager assignment and denied Ladmin assignment | -| P0 | Forged admin role attributes | Security/AdminGuardTests, RolePolicyTests; Integration/RegistrationIntegrationTests | Implemented persisted-assignment guard and typed-ID root behavior, plus real context-factory replacement of forged typed Ladmin attributes with stored Owner identity; HTTP/cookie tampering untested | -| P0 | Foreign/missing targets and sessions | Authorization/SandboxPolicyTests | Implemented read/update/delete/session/role decisions and positive controls, both owners/members; no real session rows | -| P0 | Batch targets, owner deletion, inactive sandbox | Authorization/SandboxPolicyTests | Implemented policy checks against PostgreSQL | -| P0 | Spoofed actor during provisioning | Provisioning/MemberProvisioningTests | Implemented mismatched actor and regular-member denials before framework calls | -| P0 | Provisioning token forgery | Provisioning/ProvisioningPolicyTests | Implemented bool/string impostors rejected by actual policy | -| P0 | Fault rollback and token reuse | Provisioning/MemberProvisioningTests | Implemented injected assignment failure, exact target, unrelated target, one-shot consumption and post-operation revocation; focused framework deletion mocked; real-account hard deletion after injected role-store failure also covered | -| P0 | Registration relationships | Provisioning/RegistrationTests | Implemented trusted-context handoff, PostgreSQL account/Owner-role/sandbox/membership creation, and absence of Ladmin assignment; both real-persistence and focused mock cases | -| P0 | Relational atomicity/ownership | Integration/SandboxPersistenceTests | Implemented duplicate-owner rollback, concurrent owner uniqueness, no cross-sandbox membership | -| P0 | User capacity contention | Integration/SandboxPersistenceTests | Implemented two independent serializable operations competing for one slot; any scheduling allowed, no sleeps | -| P0 | Deletion membership lifecycle | Services/AccountDeletionTests, Integration/SandboxPersistenceTests | Implemented success/failure ordering, owner restrictions and cross-sandbox preservation; actual framework cleanup untested | -| P0 | Repair scope | Services/RepairTests | Implemented stale-member cleanup restricted to one sandbox, owners preserved; owner/admin repair paths untested | -| P0 | Wrong system role ID | Regression/SystemRoleIdentityTests | Executed and reproduced failing rejection; corrupted store scenario, not demonstrated external exploit | -| P1 | Demo store permissions | Services/DemoStoreTests | Implemented Manager/Customer controls, wildcard/admin filtering, suspended denial; role persistence mocked | -| P1 | Status transitions | Authorization/SandboxPolicyTests | Active/passive/suspended behavior covered; authorized transition APIs untested | -| P1 | Identifier availability/duplicate registration | Integration/RegistrationIntegrationTests | Duplicate registration covered with real framework PostgreSQL composition; standalone availability interface not yet covered | -| P1 | Diagnostics authorization | Authorization/SandboxPolicyTests | Session read policy only; diagnostics UI/endpoint transport untested | -| P1 | Account management | Services/AccountDeletionTests | Partial: deletion ordering only; credentials/status/profile endpoint composition untested | -| P2 | Seed contributor end-to-end repeatability | Integration/RegistrationIntegrationTests | Implemented real PostgreSQL seeding, repeat execution, fixed admin assignment count and exact Manager permissions | -| P2 | Full HTTP/cookie/CSRF/context trust | β€” | Not implemented | -| P2 | Faults across both real DbContexts and cancellation/restart | Integration/RegistrationIntegrationTests | Real member-account hard-delete rollback after role-store fault covered; registration faults, cancellation and process restart remain untested | - -Passing the implemented tests cannot prove that Ladmin is unreachable through every Identity Lab operation. Highest-priority remaining work is authenticated transport tests for caller-supplied identities, registration failure recovery, and full cleanup/cancellation/restart fault coverage. Both DbContexts and production migrations are exercised, but no test runs the complete hosted HTTP application. diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj index d282dbc5..dc1e7447 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj @@ -1,20 +1,16 @@ ο»Ώ - net10.0 enable enable false - - - - - - + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs index e11741dc..62be846f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -91,6 +95,7 @@ public static AccessContext ForUserCreation( targetUserKey: null, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs index 61190150..36553b50 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs @@ -56,7 +56,7 @@ await EfCoreTestRuntime.CreateAsync( }; var exception = - await Assert.ThrowsAsync(() => service.CreateUserAsync(context, request)); + await Assert.ThrowsAsync(() => service.CreateUserAsync(context, request, TestContext.Current.CancellationToken)); exception.Message.Should().Be("simulated_identifier_store_failure"); @@ -96,17 +96,17 @@ await lifecycleFactory .GetAsync( new UserLifecycleKey( tenant, - userKey)); + userKey), TestContext.Current.CancellationToken); var profiles = await profileFactory .Create(tenant) - .GetAllProfilesByUserAsync(userKey); + .GetAllProfilesByUserAsync(userKey, TestContext.Current.CancellationToken); var identifiers = await identifierFactory .Create(tenant) - .GetByUserAsync(userKey); + .GetByUserAsync(userKey, TestContext.Current.CancellationToken); // // Atomicity contract: diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs index 67b0454c..6e75fc0c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs @@ -54,7 +54,7 @@ public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() { UserName = $"{prefix}-{name}", DisplayName = $"{prefix}-{name}" - }); + }, TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -81,7 +81,7 @@ public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() PageSize = 0, Search = prefix, SortBy = nameof(UserSummary.DisplayName) - }); + }, TestContext.Current.CancellationToken); firstPage.TotalCount.Should().Be(5); firstPage.PageNumber.Should().Be(1); @@ -106,7 +106,7 @@ public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() PageSize = 100, Search = prefix, SortBy = nameof(UserSummary.DisplayName) - }); + }, TestContext.Current.CancellationToken); secondPage.TotalCount.Should().Be(5); secondPage.PageNumber.Should().Be(2); @@ -163,7 +163,7 @@ public async Task QueryUsersAsync_AcrossPages_ReturnsEveryUserExactlyOnce() { UserName = $"{prefix}-{name}", DisplayName = $"{prefix}-{name}" - }); + }, TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -191,7 +191,7 @@ public async Task QueryUsersAsync_AcrossPages_ReturnsEveryUserExactlyOnce() PageSize = 2, Search = prefix, SortBy = nameof(UserSummary.DisplayName) - }); + }, TestContext.Current.CancellationToken); pages.Add(result); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs index c7fc3b1f..0378b5a6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs @@ -1 +1,3 @@ -ο»Ώ[assembly: CollectionBehavior(DisableTestParallelization = true)] +ο»Ώusing Xunit.v3; + +[assembly: Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs index 46245237..419a181e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs @@ -1,5 +1,4 @@ ο»Ώusing Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.VisualStudio.TestPlatform.TestHost; namespace CodeBeam.UltimateAuth.Tests.Integration; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs index 9e1a9ff9..73de9bf3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs @@ -29,9 +29,9 @@ public async Task AssignRole_TargetMismatch_ShouldRejectWithoutMutation() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.AssignAdmin); @@ -39,7 +39,7 @@ public async Task AssignRole_TargetMismatch_ShouldRejectWithoutMutation() using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, UserRolesUrl(target.UserKey, "assign"), new AssignRoleRequest { @@ -61,9 +61,9 @@ public async Task RemoveRole_TargetMismatch_ShouldRejectWithoutMutation() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.RemoveAdmin); @@ -81,7 +81,7 @@ await GrantAsync(factory, admin, { UserKey = other.UserKey, RoleName = role.Name - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -97,7 +97,7 @@ public async Task RenameRole_TargetMismatch_ShouldRejectBothTargets() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.RenameAdmin); @@ -113,7 +113,7 @@ await GrantAsync(factory, admin, { Id = other.Id, Name = "unexpected-name" - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -129,7 +129,7 @@ public async Task SetPermissions_TargetMismatch_ShouldNotModifyEitherRole() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.SetPermissionsAdmin); @@ -145,7 +145,7 @@ await GrantAsync(factory, admin, { RoleId = other.Id, Permissions = [Permission.From("test.resource.write")] - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -161,7 +161,7 @@ public async Task DeleteRole_TargetMismatch_ShouldPreserveBothRoles() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.DeleteAdmin); @@ -176,7 +176,7 @@ await GrantAsync(factory, admin, new DeleteRoleRequest { Id = other.Id - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -196,7 +196,7 @@ public async Task CreateRole_ValidRequest_ShouldPersistRole() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.CreateAdmin); @@ -206,7 +206,7 @@ await GrantAsync(factory, admin, using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{AdminBase}/roles/create", new CreateRoleRequest { @@ -228,7 +228,7 @@ public async Task RenameRole_ValidRequest_ShouldPersistNewName() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.RenameAdmin); @@ -238,7 +238,8 @@ await GrantAsync(factory, admin, using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, RoleUrl(role.Id, "rename"), new RenameRoleRequest { @@ -261,7 +262,7 @@ public async Task SetPermissions_ValidRequest_ShouldReplacePermissions() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync( factory, @@ -277,7 +278,8 @@ await GrantAsync( using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, RoleUrl(role.Id, "permissions"), new SetRolePermissionsRequest { @@ -307,8 +309,8 @@ public async Task AssignRole_ValidRequest_ShouldPersistAssignment() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.AssignAdmin); @@ -317,7 +319,7 @@ await GrantAsync(factory, admin, using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, UserRolesUrl(target.UserKey, "assign"), new AssignRoleRequest { @@ -336,8 +338,8 @@ public async Task RemoveRole_ValidRequest_ShouldRemoveAssignment() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.RemoveAdmin); @@ -348,7 +350,8 @@ await GrantAsync(factory, admin, using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, UserRolesUrl(target.UserKey, "remove"), new RemoveRoleRequest { @@ -367,8 +370,8 @@ public async Task DeleteRole_ValidRequest_ShouldRemoveAssignments() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.DeleteAdmin); @@ -379,7 +382,7 @@ await GrantAsync(factory, admin, using var client = await AuthenticatedClientAsync(factory, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, RoleUrl(role.Id, "delete"), new DeleteRoleRequest { @@ -409,8 +412,8 @@ public async Task GetUserRoles_ShouldReturnAssignedRole() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.GetAdmin); @@ -423,12 +426,12 @@ await GrantAsync(factory, admin, using var response = await client.PostAsJsonAsync( UserRolesUrl(target.UserKey, "get"), - new RoleQuery()); + new RoleQuery(), cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.UserKey.Should().Be(target.UserKey); @@ -441,7 +444,7 @@ public async Task GetMyRoles_ShouldReturnOwnRole() { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var role = await SeedRoleAsync(factory); await SeedAssignmentAsync(factory, user.UserKey, role.Id); @@ -450,12 +453,12 @@ public async Task GetMyRoles_ShouldReturnOwnRole() using var response = await client.PostAsJsonAsync( $"{SelfBase}/roles/get", - new RoleQuery()); + new RoleQuery(), cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.UserKey.Should().Be(user.UserKey); @@ -468,7 +471,7 @@ public async Task QueryRoles_ShouldReturnCreatedRole() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync( factory, @@ -481,12 +484,12 @@ await GrantAsync( using var response = await client.PostAsJsonAsync( $"{AdminBase}/roles/query", - new RoleQuery()); + new RoleQuery(), cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -503,12 +506,12 @@ public async Task AssignRole_Anonymous_ShouldReject() { using var factory = NewFactory(); - var target = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var role = await SeedRoleAsync(factory); using var client = CreateClient(factory); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, UserRolesUrl(target.UserKey, "assign"), new AssignRoleRequest { @@ -527,13 +530,13 @@ public async Task AssignRole_WithoutPermission_ShouldNotMutate() { using var factory = NewFactory(); - var actor = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var actor = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var role = await SeedRoleAsync(factory); using var client = await AuthenticatedClientAsync(factory, actor); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, UserRolesUrl(target.UserKey, "assign"), new AssignRoleRequest { @@ -552,12 +555,12 @@ public async Task CreateRole_WithoutPermission_ShouldNotCreateRole() { using var factory = NewFactory(); - var actor = await factory.CreateLoginUserAsync(); + var actor = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var name = UniqueRoleName(); using var client = await AuthenticatedClientAsync(factory, actor); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{AdminBase}/roles/create", new CreateRoleRequest { @@ -576,7 +579,7 @@ public async Task AuthorizationCheck_WithPermission_ShouldAllow() { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync( factory, @@ -585,7 +588,7 @@ await GrantAsync( using var client = await AuthenticatedClientAsync(factory, user); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{SelfBase}/check", new AuthorizationCheckRequest { @@ -596,7 +599,7 @@ await GrantAsync( response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsAllowed.Should().BeTrue(); @@ -607,11 +610,11 @@ public async Task AuthorizationCheck_WithoutPermission_ShouldReturnDenial() { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = await AuthenticatedClientAsync(factory, user); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{SelfBase}/check", new AuthorizationCheckRequest { @@ -622,7 +625,7 @@ public async Task AuthorizationCheck_WithoutPermission_ShouldReturnDenial() response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsAllowed.Should().BeFalse(); @@ -634,9 +637,9 @@ public async Task AssignRole_ServiceTargetMismatch_ShouldReject() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var authorizedTarget = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var authorizedTarget = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync( factory, @@ -675,9 +678,9 @@ public async Task RemoveRole_ServiceTargetMismatch_ShouldReject() { using var factory = NewFactory(); - var admin = await factory.CreateLoginUserAsync(); - var authorizedTarget = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var authorizedTarget = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await GrantAsync( factory, @@ -741,13 +744,13 @@ public async Task RoleStore_ShouldIsolateRolesBetweenTenants() permissions: [], now: factory.Clock.UtcNow); - await storeFactory.Create(tenantA).AddAsync(role); + await storeFactory.Create(tenantA).AddAsync(role, TestContext.Current.CancellationToken); var fromA = await storeFactory.Create(tenantA) - .GetAsync(new RoleKey(tenantA, role.Id)); + .GetAsync(new RoleKey(tenantA, role.Id), TestContext.Current.CancellationToken); var fromB = await storeFactory.Create(tenantB) - .GetAsync(new RoleKey(tenantB, role.Id)); + .GetAsync(new RoleKey(tenantB, role.Id), TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); @@ -758,7 +761,7 @@ public async Task GetMyPermissions_WhenRolePermissionsChange_ReturnsUpdatedPermi { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var oldPermission = Permission.From("reports.read"); var newPermission = Permission.From("reports.export"); @@ -775,14 +778,14 @@ await SeedAssignmentAsync( using var client = await AuthenticatedClientAsync(factory, user); // First request: the user has the original permission. - using (var response = await client.PostAsJsonAsync( + using (var response = await CsrfTestClient.PostJsonAsync(client, $"{SelfBase}/permissions/get", new { })) { response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -798,7 +801,7 @@ await SeedAssignmentAsync( var store = roleStoreFactory.Create(TenantKeys.Single); - var stored = await store.GetAsync(new RoleKey(TenantKeys.Single, role.Id)); + var stored = await store.GetAsync(new RoleKey(TenantKeys.Single, role.Id), TestContext.Current.CancellationToken); stored.Should().NotBeNull(); @@ -812,11 +815,11 @@ await SeedAssignmentAsync( stored.Permissions.Should().Contain(newPermission); stored.Permissions.Should().NotContain(oldPermission); - await store.SaveAsync(stored, expectedVersion); + await store.SaveAsync(stored, expectedVersion, TestContext.Current.CancellationToken); // Check persistence using a fresh read. var saved = await store.GetAsync( - new RoleKey(TenantKeys.Single, role.Id)); + new RoleKey(TenantKeys.Single, role.Id), TestContext.Current.CancellationToken); saved.Should().NotBeNull(); saved!.Permissions.Should().Contain(newPermission); @@ -840,7 +843,7 @@ await SeedAssignmentAsync( // 1. Was the role updated? var updatedRole = await roleStore.GetAsync( - new RoleKey(TenantKeys.Single, role.Id)); + new RoleKey(TenantKeys.Single, role.Id), TestContext.Current.CancellationToken); updatedRole.Should().NotBeNull(); @@ -849,14 +852,14 @@ await SeedAssignmentAsync( // 2. Does the user still have the role? var assignments = await userRoleStore.GetAssignmentsAsync( - user.UserKey); + user.UserKey, TestContext.Current.CancellationToken); assignments.Should().Contain(x => x.RoleId == role.Id); // 3. Can the permission store resolve the updated permission? var permissions = await permissionStore.GetPermissionsAsync( TenantKeys.Single, - user.UserKey); + user.UserKey, TestContext.Current.CancellationToken); permissions.Should().Contain(newPermission); permissions.Should().NotContain(oldPermission); @@ -865,12 +868,12 @@ await SeedAssignmentAsync( // Second request: the new permission must be visible. using (var response = await client.PostAsJsonAsync( $"{SelfBase}/permissions/get", - new { })) + new { }, cancellationToken: TestContext.Current.CancellationToken)) { response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -888,7 +891,7 @@ public async Task GetMyPermissions_Anonymous_ShouldReturnUnauthorized() using var response = await client.PostAsJsonAsync( $"{SelfBase}/permissions/get", - new { }); + new { }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } @@ -898,18 +901,18 @@ public async Task GetMyPermissions_WhenNoRoles_ReturnsEmpty() { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = await AuthenticatedClientAsync(factory, user); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{SelfBase}/permissions/get", new { }); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Permissions.Should().BeEmpty(); @@ -920,8 +923,8 @@ public async Task GetMyPermissions_DoesNotReturnOtherUsersPermissions() { using var factory = NewFactory(); - var user = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var permission = Permission.From("reports.export"); @@ -936,14 +939,14 @@ await SeedAssignmentAsync( using var client = await AuthenticatedClientAsync(factory, user); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"{SelfBase}/permissions/get", new { }); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -967,15 +970,10 @@ private static string UniqueRoleName() private static string RoleUrl(RoleId id, string operation) => $"{AdminBase}/roles/{id.Value}/{operation}"; - private static string UserRolesUrl( - UserKey userKey, - string operation) + private static string UserRolesUrl(UserKey userKey, string operation) => $"{AdminBase}/users/{userKey.Value}/roles/{operation}"; - private static async Task GrantAsync( - AuthServerFactory factory, - IntegrationTestUser user, - params string[] permissions) + private static async Task GrantAsync(AuthServerFactory factory, IntegrationTestUser user, params string[] permissions) { await factory.GrantPermissionsAsync( user.UserKey, @@ -1002,13 +1000,11 @@ private static HttpClient CreateClient(AuthServerFactory factory) return client; } - private static async Task AuthenticatedClientAsync( - AuthServerFactory factory, - IntegrationTestUser user) + private static async Task AuthenticatedClientAsync(AuthServerFactory factory, IntegrationTestUser user) { var client = CreateClient(factory); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -1032,9 +1028,7 @@ private static async Task AuthenticatedClientAsync( return client; } - private static async Task SeedRoleAsync( - AuthServerFactory factory, - IEnumerable? permissions = null) + private static async Task SeedRoleAsync(AuthServerFactory factory, IEnumerable? permissions = null) { using var scope = factory.Services.CreateScope(); @@ -1055,9 +1049,7 @@ await storeFactory return role; } - private static async Task ReadRoleAsync( - AuthServerFactory factory, - RoleId roleId) + private static async Task ReadRoleAsync(AuthServerFactory factory, RoleId roleId) { using var scope = factory.Services.CreateScope(); @@ -1069,9 +1061,7 @@ await storeFactory .GetAsync(new RoleKey(TenantKeys.Single, roleId)); } - private static async Task FindRoleByNameAsync( - AuthServerFactory factory, - string name) + private static async Task FindRoleByNameAsync(AuthServerFactory factory, string name) { using var scope = factory.Services.CreateScope(); @@ -1083,10 +1073,7 @@ await storeFactory .GetByNameAsync(name.ToUpperInvariant()); } - private static async Task SeedAssignmentAsync( - AuthServerFactory factory, - UserKey userKey, - RoleId roleId) + private static async Task SeedAssignmentAsync(AuthServerFactory factory, UserKey userKey, RoleId roleId) { using var scope = factory.Services.CreateScope(); @@ -1098,10 +1085,7 @@ await storeFactory .AssignAsync(userKey, roleId, factory.Clock.UtcNow); } - private static async Task HasRoleAsync( - AuthServerFactory factory, - UserKey userKey, - RoleId roleId) + private static async Task HasRoleAsync(AuthServerFactory factory, UserKey userKey, RoleId roleId) { using var scope = factory.Services.CreateScope(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs new file mode 100644 index 00000000..5d6a180a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class ClientRateLimitingIntegrationTests +{ + [Fact] + public async Task NativeValidation429PreservesClientState_AndLaterRevocationStillClearsIt() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() { PermitLimit = 1 }; }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + using var http = await Login(factory, user); + var transport = new HttpTransport(http); + using var services = ClientServices(factory, transport); + using var scope = services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); + Assert.True(manager.State.IsAuthenticated); + var validatedAt = manager.State.LastValidatedAt; + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + Assert.True(manager.State.IsAuthenticated); + Assert.Equal(validatedAt, manager.State.LastValidatedAt); + Assert.Equal(2, transport.Count); + using (var serverScope = factory.Services.CreateScope()) + { + var store = serverScope.ServiceProvider.GetRequiredService().Create(TenantKey.Single); + await store.ExecuteAsync(ct => store.RevokeRootCascadeAsync(user.UserKey, factory.Clock.UtcNow, ct), TestContext.Current.CancellationToken); + } + factory.Clock.Advance(TimeSpan.FromMinutes(2)); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + Assert.False(manager.State.IsAuthenticated); + } + + [Fact] + public async Task NativeRefresh429PreservesCredentials_AndSuppressesImmediateRefreshLoop() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() { PermitLimit = 1 }; }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + using var http = await Login(factory, user); + var credential = http.DefaultRequestHeaders.GetValues("Cookie").Single(); + var transport = new HttpTransport(http); + using var services = ClientServices(factory, transport); + using var scope = services.CreateScope(); + var flow = scope.ServiceProvider.GetRequiredService().Flows; + Assert.True((await flow.RefreshAsync()).IsSuccess); + var throttle = await flow.RefreshAsync(); + Assert.Equal(429, throttle.Status); + Assert.Equal("rate_limited", throttle.ErrorCode); + Assert.Equal(RefreshOutcome.None, throttle.Outcome); + Assert.Equal(credential, http.DefaultRequestHeaders.GetValues("Cookie").Single()); + await flow.RefreshAsync(isAuto: true); + Assert.Equal(2, transport.Count); + var session = await CsrfTestClient.PostAsync(http, "/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, session.StatusCode); + Assert.True((await session.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken))!.IsValid); + } + + private static async Task Login(AuthServerFactory factory, IntegrationTestUser user) + { + var http = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + http.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + http.DefaultRequestHeaders.Add("X-UDID", "client-rate-device-123456789"); + http.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", "BlazorServer"); + var login = await CsrfTestClient.PostJsonAsync(http, "/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + Assert.True(login.Headers.TryGetValues("Set-Cookie", out var cookies)); + http.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies!.Select(c => c.Split(';')[0]))); + return http; + } + + private static ServiceProvider ClientServices(AuthServerFactory factory, HttpTransport transport) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddUltimateAuthClient(o => { o.ClientProfile = UAuthClientProfile.BlazorServer; o.AutoDetectClientProfile = false; }); + services.AddSingleton(); + services.AddSingleton(factory.Clock); + services.AddSingleton(transport); + services.AddSingleton(transport); + services.AddSingleton(transport); + services.AddSingleton(transport); + return services.BuildServiceProvider(); + } + + // Thin HTTP test adapter: public SDK contracts against the real auth host and native middleware. + private sealed class HttpTransport(HttpClient http) : IUAuthRequestClient, IUAuthResourceRequestClient, IClientDeviceProvider, IReturnUrlProvider + { + internal int Count { get; private set; } + public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetAsync() => Task.FromResult(DeviceContext.Anonymous()); + public string GetCurrentUrl() => "/"; + public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + Count++; + using var response = await CsrfTestClient.PostAsync(http, endpoint, form is null ? null : new FormUrlEncodedContent(form)); + return await Read(response, ct); + } + public async Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default) + { + Count++; + using var response = await CsrfTestClient.PostJsonAsync(http, endpoint, payload); + return await Read(response, ct); + } + private static async Task Read(HttpResponseMessage response, CancellationToken ct) + { + var body = await response.Content.ReadAsStringAsync(ct); + return new() + { + Status = (int)response.StatusCode, + Ok = response.IsSuccessStatusCode, + RetryAfter = response.Headers.TryGetValues("Retry-After", out var retry) ? retry.First() : null, + RefreshOutcome = response.Headers.TryGetValues("X-UAuth-Refresh", out var outcome) ? outcome.First() : null, + Body = string.IsNullOrWhiteSpace(body) ? null : JsonSerializer.Deserialize(body) + }; + } + public Task SendAsync(string method, string endpoint, object? payload = null, CancellationToken ct = default) => throw new NotSupportedException(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj index 956c4a59..b6a5b7a8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj @@ -1,22 +1,18 @@ ο»Ώ - net10.0 enable enable false - - - - - - - - + + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs index 95540fef..1075fc27 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -33,7 +33,7 @@ public async Task AnonymousReset_WithValidToken_ChangesPassword() factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -44,7 +44,7 @@ public async Task AnonymousReset_WithValidToken_ChangesPassword() Identifier = user.Identifier, CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token - }); + }, cancellationToken: TestContext.Current.CancellationToken); beginResponse.StatusCode.Should().Be(HttpStatusCode.OK); @@ -63,7 +63,7 @@ public async Task AnonymousReset_WithValidToken_ChangesPassword() CredentialType = CredentialType.Password, ResetToken = notification.Token, NewSecret = newPassword - }); + }, cancellationToken: TestContext.Current.CancellationToken); completeResponse.StatusCode.Should().Be(HttpStatusCode.OK); @@ -78,7 +78,7 @@ public async Task AnonymousReset_WithValidToken_ChangesPassword() var store = storeFactory.Create(notification.Tenant); - var credentials = await store.GetByUserAsync(user.UserKey); + var credentials = await store.GetByUserAsync(user.UserKey, TestContext.Current.CancellationToken); var password = credentials .OfType() @@ -119,13 +119,13 @@ public async Task AdminBeginReset_TargetMismatch_ShouldRejectWithoutNotification using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Credentials.BeginResetAdmin]); + [UAuthActions.Credentials.BeginResetAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); @@ -149,12 +149,12 @@ public async Task AdminBeginReset_ValidTarget_ShouldNotifyTarget() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Credentials.BeginResetAdmin]); + [UAuthActions.Credentials.BeginResetAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); @@ -167,7 +167,7 @@ await factory.GrantPermissionsAsync( response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); @@ -175,7 +175,7 @@ await factory.GrantPermissionsAsync( notifier.Notifications.Should() .ContainSingle(x => x.UserKey == target.UserKey); - var body = await response.Content.ReadAsStringAsync(); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); body.Should().NotContain( notifier.Notifications.Single().Token); @@ -189,13 +189,13 @@ public async Task AdminCompleteReset_TargetMismatch_ShouldNotChangeOtherPassword using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Credentials.CompleteResetAdmin]); + [UAuthActions.Credentials.CompleteResetAdmin], TestContext.Current.CancellationToken); // B iΓ§in gerΓ§ekten geΓ§erli bir reset token'Δ± ΓΌret. using var anonymousClient = CreateClient(factory); @@ -234,21 +234,20 @@ public async Task AdminCompleteReset_ValidTarget_ShouldChangePassword() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Credentials.BeginResetAdmin, UAuthActions.Credentials.CompleteResetAdmin - ]); + ], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - var url = - $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; + var url = $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; var begin = await BeginResetAsync( client, @@ -258,7 +257,7 @@ await factory.GrantPermissionsAsync( begin.StatusCode.Should().Be(HttpStatusCode.OK); var beginResult = await begin.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); beginResult.Should().NotBeNull(); @@ -278,7 +277,7 @@ await factory.GrantPermissionsAsync( complete.StatusCode.Should().Be(HttpStatusCode.OK); var completeResult = await complete.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); completeResult.Should().NotBeNull(); completeResult!.IsSuccess.Should().BeTrue(); @@ -295,7 +294,7 @@ public async Task AnonymousCompleteReset_ReusedToken_ShouldReject() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -341,8 +340,8 @@ public async Task AdminBeginReset_WithoutPermission_ShouldNotNotify() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var actor = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var actor = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, actor); @@ -364,7 +363,7 @@ public async Task AdminBeginReset_Unauthenticated_ShouldReject() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var target = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -386,7 +385,7 @@ public async Task AnonymousCompleteReset_InvalidProof_ShouldNotRevealIdentifierE using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var existingUser = await factory.CreateLoginUserAsync(); + var existingUser = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -412,8 +411,8 @@ public async Task AnonymousCompleteReset_InvalidProof_ShouldNotRevealIdentifierE .Be(unknownResponse.StatusCode); // Same observable response body. - var existingBody = await existingResponse.Content.ReadAsStringAsync(); - var unknownBody = await unknownResponse.Content.ReadAsStringAsync(); + var existingBody = await existingResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var unknownBody = await unknownResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); existingBody.Should().Be(unknownBody); @@ -432,7 +431,7 @@ public async Task AnonymousRecovery_DefaultPolicy_RevokesAllExistingChains() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); // Two independent clients with different X-UDID values. using var firstClient = CreateClient(factory); @@ -449,7 +448,7 @@ public async Task AnonymousRecovery_DefaultPolicy_RevokesAllExistingChains() var store = sessionFactory.Create(TenantKeys.Single); - var chains = await store.GetChainsByUserAsync(user.UserKey); + var chains = await store.GetChainsByUserAsync(user.UserKey, ct: TestContext.Current.CancellationToken); chains.Should().HaveCount(2); chains.Should().OnlyContain(c => !c.IsRevoked); @@ -492,7 +491,7 @@ public async Task AnonymousRecovery_DefaultPolicy_RevokesAllExistingChains() var store = sessionFactory.Create(TenantKeys.Single); - var chains = await store.GetChainsByUserAsync(user.UserKey); + var chains = await store.GetChainsByUserAsync(user.UserKey, ct: TestContext.Current.CancellationToken); chains.Should().HaveCount(2); chains.Should().OnlyContain(c => c.IsRevoked); @@ -519,7 +518,7 @@ public async Task Recovery_ShouldRespectConfiguredSessionPolicy(CredentialSessio factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var firstClient = CreateClient(factory); using var secondClient = CreateClient(factory); @@ -579,8 +578,8 @@ public async Task Recovery_RevokeAll_ShouldNotAffectOtherUsers() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var targetClient = CreateClient(factory); using var otherClient = CreateClient(factory); @@ -631,7 +630,7 @@ public async Task Recovery_InvalidToken_ShouldNotRevokeExistingChains() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var authenticatedClient = CreateClient(factory); await AuthenticateAsync(authenticatedClient, user); @@ -676,7 +675,7 @@ public async Task SelfChange_ShouldRespectConfiguredSessionPolicy(CredentialSess factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var currentClient = CreateClient(factory); using var otherClient = CreateClient(factory); @@ -698,7 +697,7 @@ public async Task SelfChange_ShouldRespectConfiguredSessionPolicy(CredentialSess var newPassword = $"Changed-{Guid.NewGuid():N}!"; - var response = await currentClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(currentClient, "/auth/me/credentials/change", new ChangeCredentialRequest { @@ -732,12 +731,12 @@ public async Task AdminChange_ShouldRespectConfiguredSessionPolicy(CredentialSes factory.Clock.Reset(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Credentials.ChangeAdmin]); + [UAuthActions.Credentials.ChangeAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient(factory); using var targetClient1 = CreateClient(factory); @@ -753,7 +752,8 @@ await factory.GrantPermissionsAsync( var newPassword = $"AdminChanged-{Guid.NewGuid():N}!"; - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync( + adminClient, $"/auth/admin/users/{target.UserKey.Value}/credentials/change", new ChangeCredentialRequest { @@ -782,7 +782,7 @@ public async Task AnonymousBeginReset_AlreadyActive_ShouldNotRevealResetState() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -807,8 +807,8 @@ public async Task AnonymousBeginReset_AlreadyActive_ShouldNotRevealResetState() active.StatusCode.Should().Be(unknown.StatusCode); - (await active.Content.ReadAsStringAsync()) - .Should().Be(await unknown.Content.ReadAsStringAsync()); + (await active.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) + .Should().Be(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); notifier.Notifications.Should().HaveCount(notificationCount); } @@ -821,7 +821,7 @@ public async Task AnonymousCompleteReset_ExpiredToken_ShouldNotRevealResetState( using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -854,8 +854,8 @@ public async Task AnonymousCompleteReset_ExpiredToken_ShouldNotRevealResetState( expired.StatusCode.Should().Be(unknown.StatusCode); - (await expired.Content.ReadAsStringAsync()) - .Should().Be(await unknown.Content.ReadAsStringAsync()); + (await expired.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) + .Should().Be(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); await AssertPasswordAsync(factory, user, user.Secret); } @@ -868,7 +868,7 @@ public async Task AnonymousBeginReset_InvalidCodeType_ShouldRejectConsistently() using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -892,8 +892,8 @@ async Task SendAsync(string identifier) existing.StatusCode.Should().Be(HttpStatusCode.BadRequest); unknown.StatusCode.Should().Be(HttpStatusCode.BadRequest); - using var existingJson = JsonDocument.Parse(await existing.Content.ReadAsStringAsync()); - using var unknownJson = JsonDocument.Parse(await unknown.Content.ReadAsStringAsync()); + using var existingJson = JsonDocument.Parse(await existing.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var unknownJson = JsonDocument.Parse(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); existingJson.RootElement.GetProperty("status").GetInt32() .Should().Be(unknownJson.RootElement.GetProperty("status").GetInt32()); @@ -915,7 +915,7 @@ public async Task AnonymousCompleteReset_SamePassword_ShouldNotRevealCredentialS using var factory = CreateFactory(notifier); factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); @@ -944,8 +944,8 @@ public async Task AnonymousCompleteReset_SamePassword_ShouldNotRevealCredentialS samePassword.StatusCode.Should().Be(unknown.StatusCode); - (await samePassword.Content.ReadAsStringAsync()) - .Should().Be(await unknown.Content.ReadAsStringAsync()); + (await samePassword.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) + .Should().Be(await unknown.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); await AssertPasswordAsync(factory, user, user.Secret); } @@ -1001,7 +1001,7 @@ private static AuthServerFactory CreateFactory(TestResetNotifier notifier) private static Task BeginResetAsync(HttpClient client, string url, string identifier) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, url, new BeginResetCredentialRequest { @@ -1013,7 +1013,7 @@ private static Task BeginResetAsync(HttpClient client, stri private static Task CompleteResetAsync(HttpClient client, string url, string identifier, string token, string newPassword) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, url, new CompleteResetCredentialRequest { @@ -1026,7 +1026,8 @@ private static Task CompleteResetAsync(HttpClient client, s private static async Task AuthenticateAsync(HttpClient client, IntegrationTestUser user) { - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync( + client, "/auth/login", new { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs index 22ea0305..47f833fb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs @@ -65,7 +65,7 @@ public async Task InvalidJsonRequest_ShouldReturn400ProblemDetails( request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - using var response = await host.Client.SendAsync(request); + using var response = await host.Client.SendAsync(request, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -73,7 +73,7 @@ public async Task InvalidJsonRequest_ShouldReturn400ProblemDetails( .MediaType.Should().Be("application/problem+json"); var problem = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); problem.Should().NotBeNull(); problem!.Status.Should().Be(400); @@ -89,12 +89,12 @@ public async Task ValidJsonRequest_ShouldReturn200WithTypedResult() using var response = await host.Client.PostAsJsonAsync( Endpoint, - new { name = "UltimateAuth" }); + new { name = "UltimateAuth" }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Name.Should().Be("UltimateAuth"); @@ -113,12 +113,12 @@ public async Task MissingContentType_ShouldReturn400ProblemDetails() Encoding.UTF8.GetBytes("""{"name":"test"}""")) }; - using var response = await host.Client.SendAsync(request); + using var response = await host.Client.SendAsync(request, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var problem = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); problem!.Detail.Should().Be("request_content_type_invalid"); } @@ -130,12 +130,12 @@ public async Task JsonReader_ShouldRewindBodyAfterSuccess() using var response = await host.Client.PostAsJsonAsync( "/test/json-rewind", - new { name = "UltimateAuth" }); + new { name = "UltimateAuth" }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Name.Should().Be("UltimateAuth"); @@ -161,7 +161,7 @@ public async Task InvalidPermission_ShouldReturn400ProblemDetails() using var response = await host.Client.PostAsync( "/test/json-permission", - content); + content, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -169,7 +169,7 @@ public async Task InvalidPermission_ShouldReturn400ProblemDetails() .MediaType.Should().Be("application/problem+json"); var problem = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); problem.Should().NotBeNull(); problem!.Status.Should().Be(400); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs index 025b4dec..a07c17cd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs @@ -61,6 +61,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton(Clock); + // Business-flow tests share a host; limiter contracts use isolated hosts. + services.PostConfigure(options => options.RateLimiting.Enabled = false); + if (_configureServer is not null) { services.PostConfigure(options => _configureServer(options)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/CsrfTestClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/CsrfTestClient.cs new file mode 100644 index 00000000..2aca4bdd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/CsrfTestClient.cs @@ -0,0 +1,138 @@ +ο»Ώusing System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; + +internal static class CsrfTestClient +{ + private sealed record TokenResponse(string RequestToken, string HeaderName, string FormFieldName); + + public static Task PostJsonAsync( + HttpClient client, + string url, + T payload, + string tokenUrl = "/auth/antiforgery/token") + { + return SendAsync( + client, + url, + JsonContent.Create(payload), + tokenUrl); + } + + public static Task PostAsync( + HttpClient client, + string url, + HttpContent? content = null, + string tokenUrl = "/auth/antiforgery/token") + { + return SendAsync(client, url, content, tokenUrl); + } + + private static async Task SendAsync( + HttpClient client, + string url, + HttpContent? content, + string tokenUrl) + { + using var tokenResponse = await client.GetAsync(tokenUrl); + + tokenResponse.EnsureSuccessStatusCode(); + + var token = await tokenResponse.Content + .ReadFromJsonAsync(); + + if (token is null || + string.IsNullOrWhiteSpace(token.RequestToken) || + string.IsNullOrWhiteSpace(token.HeaderName)) + { + throw new InvalidOperationException( + "Invalid antiforgery token response."); + } + + var antiforgeryCookie = tokenResponse.Headers + .GetValues("Set-Cookie") + .Select(value => value.Split(';', 2)[0]) + .FirstOrDefault(value => + value.TrimStart().StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(antiforgeryCookie)) + { + throw new InvalidOperationException( + "Antiforgery cookie was not issued."); + } + + using var request = new HttpRequestMessage( + HttpMethod.Post, + url) + { + Content = content + }; + + request.Headers.TryAddWithoutValidation( + token.HeaderName, + token.RequestToken); + + // Preserve existing manually managed session cookies. + var existingCookies = client.DefaultRequestHeaders.TryGetValues( + "Cookie", + out var values) + ? string.Join("; ", values) + : string.Empty; + + var cookieHeader = string.IsNullOrWhiteSpace(existingCookies) + ? antiforgeryCookie + : $"{existingCookies}; {antiforgeryCookie}"; + + request.Headers.TryAddWithoutValidation( + "Cookie", + cookieHeader); + + return await client.SendAsync(request); + } + + public static async Task SendAsync(HttpClient client, HttpRequestMessage request, string tokenUrl = "/auth/antiforgery/token") + { + using var tokenResponse = await client.GetAsync(tokenUrl); + tokenResponse.EnsureSuccessStatusCode(); + + var token = await tokenResponse.Content + .ReadFromJsonAsync(); + + if (token is null || + string.IsNullOrWhiteSpace(token.RequestToken) || + string.IsNullOrWhiteSpace(token.HeaderName)) + { + throw new InvalidOperationException( + "Invalid antiforgery token response."); + } + + request.Headers.TryAddWithoutValidation( + token.HeaderName, + token.RequestToken); + + var antiforgeryCookie = tokenResponse.Headers + .GetValues("Set-Cookie") + .Select(x => x.Split(';', 2)[0]) + .First(x => x.StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + + var existingCookies = + request.Headers.TryGetValues("Cookie", out var requestCookies) + ? string.Join("; ", requestCookies) + : client.DefaultRequestHeaders.TryGetValues("Cookie", out var clientCookies) + ? string.Join("; ", clientCookies) + : string.Empty; + + request.Headers.Remove("Cookie"); + request.Headers.TryAddWithoutValidation( + "Cookie", + string.IsNullOrWhiteSpace(existingCookies) + ? antiforgeryCookie + : $"{existingCookies}; {antiforgeryCookie}"); + + return await client.SendAsync(request); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs index 6303151e..d8e80366 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration.Helpers; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -91,6 +95,7 @@ public static AccessContext ForUserCreation( targetUserKey: null, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs new file mode 100644 index 00000000..13f6b469 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs @@ -0,0 +1,112 @@ +ο»Ώusing System.Net; +using System.Net.Http.Json; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class LoginCsrfExploitProofTests +{ + [Fact] + public async Task CrossOriginLogin_WithoutCsrfProof_MustNotAuthenticateVictimAsAttacker() + { + await using var factory = new AuthServerFactory(); + + var attacker = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var deviceId = $"csrf-victim-{Guid.NewGuid():N}"; + + using var victim = CreateBrowser(factory, deviceId); + + // The victim starts unauthenticated. + await AssertUnauthenticatedAsync(victim); + + // The attacker submits valid credentials without CSRF proof. + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = attacker.Identifier, + secret = attacker.Secret + }) + }; + + request.Headers.TryAddWithoutValidation( + "Origin", + "https://localhost:6130"); + + using var response = await victim.SendAsync(request, TestContext.Current.CancellationToken); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Prove that the rejection came from CSRF validation. + response.StatusCode.Should().Be( + HttpStatusCode.BadRequest, + $"unexpected login response: {body}"); + + body.Should().Contain( + "invalid_antiforgery_token"); + + // The rejected login must not issue a session cookie. + var sessionCookie = GetSessionCookie(response); + + sessionCookie.Should().BeNull( + "a rejected CSRF login must not establish a session"); + + // The victim must remain unauthenticated. + await AssertUnauthenticatedAsync(victim); + } + + private static async Task AssertUnauthenticatedAsync( + HttpClient client) + { + using var response = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 10 + }); + + response.StatusCode.Should().Be( + HttpStatusCode.Unauthorized); + } + + private static HttpClient CreateBrowser( + AuthServerFactory factory, + string deviceId) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.TryAddWithoutValidation( + "X-UDID", + deviceId); + + return client; + } + + private static string? GetSessionCookie( + HttpResponseMessage response) + { + if (!response.Headers.TryGetValues( + "Set-Cookie", + out var cookies)) + { + return null; + } + + return cookies + .Select(x => x.Split(';', 2)[0]) + .FirstOrDefault(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index 71fae4e0..5cdc5fc4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -9,27 +9,25 @@ namespace CodeBeam.UltimateAuth.Tests.Integration; -public class LoginTests : IClassFixture +public class LoginTests : IDisposable { private const string LoginEndpoint = "/auth/login"; private const string ValidIdentifier = "admin"; private const string ValidSecret = "admin"; - private readonly AuthServerFactory _factory; + // Each test owns its host so clock changes and account state cannot leak. + private readonly AuthServerFactory _factory = new(); - public LoginTests(AuthServerFactory factory) - { - _factory = factory; - } + public void Dispose() => _factory.Dispose(); [Fact] public async Task Login_WithValidCredentials_ShouldIssueSessionCredential() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient($"valid-session-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -49,7 +47,7 @@ public async Task Login_WithValidCredentials_ShouldIssueSessionCredential() [Fact] public async Task Login_WithValidCredentials_ShouldCreateUsableAuthenticatedSession() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"usable-session-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); var loginResponse = await LoginAsync(client, user.Identifier, user.Secret); @@ -60,7 +58,7 @@ public async Task Login_WithValidCredentials_ShouldCreateUsableAuthenticatedSess using var authenticatedClient = CreateClient(deviceId); authenticatedClient.DefaultRequestHeaders.Add("Cookie", cookie); - var response = await authenticatedClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(authenticatedClient, "/auth/me/sessions/chains", new PageRequest { @@ -70,7 +68,7 @@ public async Task Login_WithValidCredentials_ShouldCreateUsableAuthenticatedSess response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync>(); + var result = await response.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Items.Should().NotBeEmpty(); @@ -136,7 +134,7 @@ public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticateUser() using var content = new StringContent( "identifier=admin&secret=admin"); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, LoginEndpoint, content); @@ -239,7 +237,7 @@ public async Task Login_AfterLockoutExpires_ShouldAllowCorrectCredentials() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"lockout-expiry-{Guid.NewGuid():N}"); @@ -284,12 +282,12 @@ await LoginAsync( [Fact] public async Task TryLogin_WithValidCredentials_ShouldReturnSuccessfulPreview() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"try-login-valid-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -300,7 +298,7 @@ public async Task TryLogin_WithValidCredentials_ShouldReturnSuccessfulPreview() response.StatusCode.Should().Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -319,7 +317,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() using var client = CreateClient( "try-login-device-2222222222222222"); - var response = await client.PostAsJsonAsync("/auth/try-login", new + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { identifier = ValidIdentifier, secret = ValidSecret @@ -327,7 +325,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeTrue(); @@ -337,7 +335,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() new GetProfileRequest { ProfileKey = null - }); + }, cancellationToken: TestContext.Current.CancellationToken); meResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } @@ -345,12 +343,12 @@ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() [Fact] public async Task TryLogin_WithInvalidCredentials_ShouldReturnFailedPreviewWithoutSession() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"try-invalid-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -361,7 +359,7 @@ public async Task TryLogin_WithInvalidCredentials_ShouldReturnFailedPreviewWitho response.StatusCode.Should().Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeFalse(); @@ -379,7 +377,7 @@ public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview() using var client = CreateClient( "try-login-device-4444444444444444"); - var response = await client.PostAsJsonAsync("/auth/try-login", new + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { identifier = "", secret = "" @@ -387,7 +385,7 @@ public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview() response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeFalse(); @@ -398,12 +396,12 @@ public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview() [Fact] public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"preview-valid-{Guid.NewGuid():N}"); - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -414,13 +412,13 @@ public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser() previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); - var loginResponse = await client.PostAsJsonAsync( + var loginResponse = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -441,12 +439,12 @@ public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser() [Fact] public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustReceipt() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var previewClient = CreateClient( $"receipt-owner-{Guid.NewGuid():N}"); - var previewResponse = await previewClient.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(previewClient, "/auth/try-login", new { @@ -457,7 +455,7 @@ public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustRece previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); var preview = await previewResponse.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -474,7 +472,7 @@ public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustRece identifier = user.Identifier, secret = "wrong-password", previewReceipt = preview.PreviewReceipt - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.Headers .TryGetValues("Set-Cookie", out _) @@ -484,12 +482,12 @@ public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustRece [Fact] public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthenticate() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"preview-different-secret-{Guid.NewGuid():N}"); - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -498,7 +496,7 @@ public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthentica }); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -511,7 +509,7 @@ public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthentica identifier = user.Identifier, secret = "different-secret", previewReceipt = preview.PreviewReceipt - }); + }, cancellationToken: TestContext.Current.CancellationToken); loginResponse.Headers .TryGetValues("Set-Cookie", out _) @@ -521,14 +519,14 @@ public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthentica [Fact] public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackToNormalAuthentication() { - var receiptOwner = await _factory.CreateLoginUserAsync(); - var otherUser = await _factory.CreateLoginUserAsync(); + var receiptOwner = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var otherUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"preview-different-identifier-{Guid.NewGuid():N}"); // Create receipt for user A. - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -539,7 +537,7 @@ public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackT previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -549,7 +547,7 @@ public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackT // // The receipt must NOT be trusted for user B, but it also must not // prevent user B from authenticating with valid credentials. - var loginResponse = await client.PostAsJsonAsync( + var loginResponse = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -573,13 +571,13 @@ public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackT [Fact] public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypassCredentialValidation() { - var receiptOwner = await _factory.CreateLoginUserAsync(); - var otherUser = await _factory.CreateLoginUserAsync(); + var receiptOwner = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var otherUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"preview-cross-user-{Guid.NewGuid():N}"); - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -588,7 +586,7 @@ public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypas }); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -596,7 +594,7 @@ public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypas // Receipt belongs to user A. // Attempt user B authentication with INVALID credentials. - var loginResponse = await client.PostAsJsonAsync( + var loginResponse = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -613,12 +611,12 @@ public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypas [Fact] public async Task Login_WithUnknownPreviewReceipt_ShouldFallBackToNormalLoginValidation() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"unknown-receipt-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -639,12 +637,12 @@ public async Task Login_WithUnknownPreviewReceipt_ShouldFallBackToNormalLoginVal [Fact] public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"preview-consume-{Guid.NewGuid():N}"); - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -653,13 +651,13 @@ public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed() }); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); - var firstCommit = await client.PostAsJsonAsync( + var firstCommit = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -680,7 +678,7 @@ public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed() // // Reusing it must not grant any special trust. The request should // simply fall back to normal credential validation. - var secondCommit = await client.PostAsJsonAsync( + var secondCommit = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -706,13 +704,13 @@ public async Task Login_WithRepeatedInvalidCredentials_ShouldLockAccount() { identifier = ValidIdentifier, secret = "wrong-password-1" - }); + }, cancellationToken: TestContext.Current.CancellationToken); first.Headers .TryGetValues("Set-Cookie", out _) .Should().BeFalse(); - var second = await client.PostAsJsonAsync( + var second = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -731,7 +729,7 @@ public async Task Login_WithRepeatedInvalidCredentials_ShouldLockAccount() { identifier = ValidIdentifier, secret = ValidSecret - }); + }, cancellationToken: TestContext.Current.CancellationToken); correctLogin.Headers .TryGetValues("Set-Cookie", out _) @@ -744,7 +742,7 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc using var client = CreateClient( "try-lockout-device-222222222222222"); - var first = await client.PostAsJsonAsync( + var first = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -753,12 +751,12 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc }); var firstResult = - await first.Content.ReadFromJsonAsync(); + await first.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); firstResult.Should().NotBeNull(); firstResult!.IsSuccess.Should().BeFalse(); - var second = await client.PostAsJsonAsync( + var second = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -766,14 +764,13 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc secret = "wrong-password-2" }); - var secondResult = - await second.Content.ReadFromJsonAsync(); + var secondResult = await second.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); secondResult.Should().NotBeNull(); secondResult!.IsSuccess.Should().BeFalse(); // Account should now be locked. - var correctAttempt = await client.PostAsJsonAsync( + var correctAttempt = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -782,7 +779,7 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc }); var correctResult = - await correctAttempt.Content.ReadFromJsonAsync(); + await correctAttempt.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); correctResult.Should().NotBeNull(); correctResult!.IsSuccess.Should().BeFalse(); @@ -792,12 +789,12 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc [Fact] public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"try-no-failure-{Guid.NewGuid():N}"); - var preview = await client.PostAsJsonAsync( + var preview = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -806,12 +803,12 @@ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() }); var previewResult = - await preview.Content.ReadFromJsonAsync(); + await preview.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); previewResult.Should().NotBeNull(); previewResult!.IsSuccess.Should().BeTrue(); - var failure = await client.PostAsJsonAsync( + var failure = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -820,7 +817,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() }); var failureResult = - await failure.Content.ReadFromJsonAsync(); + await failure.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); failureResult.Should().NotBeNull(); failureResult!.IsSuccess.Should().BeFalse(); @@ -830,12 +827,12 @@ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() [Fact] public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAccounting() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"receipt-accounting-{Guid.NewGuid():N}"); - var previewResponse = await client.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -846,7 +843,7 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -861,7 +858,7 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc identifier = user.Identifier, secret = "wrong-password-1", previewReceipt = preview.PreviewReceipt - }); + }, cancellationToken: TestContext.Current.CancellationToken); firstFailure.Headers .TryGetValues("Set-Cookie", out _) @@ -875,7 +872,7 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc { identifier = user.Identifier, secret = "wrong-password-2" - }); + }, cancellationToken: TestContext.Current.CancellationToken); secondFailure.Headers .TryGetValues("Set-Cookie", out _) @@ -888,7 +885,7 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc { identifier = user.Identifier, secret = user.Secret - }); + }, cancellationToken: TestContext.Current.CancellationToken); correctLogin.Headers .TryGetValues("Set-Cookie", out _) @@ -898,12 +895,12 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc [Fact] public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAccounting() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var receiptClient = CreateClient( $"receipt-device-a-{Guid.NewGuid():N}"); - var previewResponse = await receiptClient.PostAsJsonAsync( + var previewResponse = await CsrfTestClient.PostJsonAsync(receiptClient, "/auth/try-login", new { @@ -912,7 +909,7 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc }); var preview = - await previewResponse.Content.ReadFromJsonAsync(); + await previewResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); preview.Should().NotBeNull(); preview!.IsSuccess.Should().BeTrue(); @@ -930,7 +927,7 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc identifier = user.Identifier, secret = "wrong-password-1", previewReceipt = preview.PreviewReceipt - }); + }, cancellationToken: TestContext.Current.CancellationToken); firstFailure.Headers .TryGetValues("Set-Cookie", out _) @@ -944,7 +941,7 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc { identifier = user.Identifier, secret = "wrong-password-2" - }); + }, cancellationToken: TestContext.Current.CancellationToken); secondFailure.Headers .TryGetValues("Set-Cookie", out _) @@ -956,7 +953,7 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc { identifier = user.Identifier, secret = user.Secret - }); + }, cancellationToken: TestContext.Current.CancellationToken); correctLogin.Headers .TryGetValues("Set-Cookie", out _) @@ -966,13 +963,13 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc [Fact] public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"success-reset-{Guid.NewGuid():N}"); // Failure #1 - var firstFailure = await client.PostAsJsonAsync( + var firstFailure = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -985,7 +982,7 @@ public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() .Should().BeFalse(); // Successful authentication must reset previous failure accounting. - var success = await client.PostAsJsonAsync( + var success = await CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -1001,7 +998,7 @@ public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() // If the previous failure was reset, this is failure #1 again, // not failure #2 / lockout. - var failureAfterSuccess = await client.PostAsJsonAsync( + var failureAfterSuccess = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -1009,8 +1006,7 @@ public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() secret = "wrong-password-2" }); - var result = - await failureAfterSuccess.Content.ReadFromJsonAsync(); + var result = await failureAfterSuccess.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeFalse(); @@ -1029,7 +1025,7 @@ await attackerDevice.PostAsJsonAsync( { identifier = ValidIdentifier, secret = "wrong-password-1" - }); + }, cancellationToken: TestContext.Current.CancellationToken); await attackerDevice.PostAsJsonAsync( "/auth/login", @@ -1037,7 +1033,7 @@ await attackerDevice.PostAsJsonAsync( { identifier = ValidIdentifier, secret = "wrong-password-2" - }); + }, cancellationToken: TestContext.Current.CancellationToken); using var differentDevice = CreateClient( "lockout-other-device-000000000000000"); @@ -1048,7 +1044,7 @@ await attackerDevice.PostAsJsonAsync( { identifier = ValidIdentifier, secret = ValidSecret - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.Headers .TryGetValues("Set-Cookie", out _) @@ -1060,7 +1056,7 @@ await attackerDevice.PostAsJsonAsync( [Fact] public async Task Login_WithFormPayload_ShouldAuthenticateUser() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"form-login-{Guid.NewGuid():N}"); @@ -1072,7 +1068,7 @@ public async Task Login_WithFormPayload_ShouldAuthenticateUser() ["Secret"] = user.Secret }); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, LoginEndpoint, content); @@ -1088,7 +1084,7 @@ public async Task Login_WithFormPayload_ShouldAuthenticateUser() [Fact] public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"form-try-login-{Guid.NewGuid():N}"); @@ -1100,14 +1096,14 @@ public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview() ["Secret"] = user.Secret }); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/try-login", content); response.StatusCode.Should().Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeTrue(); @@ -1125,14 +1121,11 @@ public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview() [InlineData("admin", " ")] [InlineData("", "")] [InlineData(" ", " ")] - public async Task Login_WithMissingOrWhitespaceCredentials_ShouldNotAuthenticate( - string identifier, - string secret) + public async Task Login_WithMissingOrWhitespaceCredentials_ShouldNotAuthenticate(string identifier, string secret) { - using var client = CreateClient( - $"invalid-input-device-{Guid.NewGuid():N}"); + using var client = CreateClient($"invalid-input-device-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -1152,14 +1145,11 @@ public async Task Login_WithMissingOrWhitespaceCredentials_ShouldNotAuthenticate [InlineData("admin", " ")] [InlineData("", "")] [InlineData(" ", " ")] - public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvalidCredentials( - string identifier, - string secret) + public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvalidCredentials(string identifier, string secret) { - using var client = CreateClient( - $"invalid-preview-device-{Guid.NewGuid():N}"); + using var client = CreateClient($"invalid-preview-device-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -1169,7 +1159,7 @@ public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvali response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeFalse(); @@ -1188,7 +1178,7 @@ public async Task TryLogin_WithUnsupportedContentType_ShouldReturnBadRequest() System.Text.Encoding.UTF8, "text/plain"); - var response = await client.PostAsync("/auth/try-login", content); + var response = await client.PostAsync("/auth/try-login", content, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -1208,7 +1198,7 @@ public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticate() System.Text.Encoding.UTF8, "text/plain"); - var response = await client.PostAsync("/auth/login", content); + var response = await client.PostAsync("/auth/login", content, TestContext.Current.CancellationToken); response.Headers .TryGetValues("Set-Cookie", out _) @@ -1224,13 +1214,13 @@ public async Task TryLogin_WithEmptyJsonObject_ShouldReturnInvalidCredentials() using var client = CreateClient( "empty-json-device-555555555555555"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { }); response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeFalse(); @@ -1246,7 +1236,7 @@ public async Task Login_WithEmptyJsonObject_ShouldNotAuthenticate() var response = await client.PostAsJsonAsync( "/auth/login", - new { }); + new { }, cancellationToken: TestContext.Current.CancellationToken); response.Headers .TryGetValues("Set-Cookie", out _) @@ -1259,7 +1249,7 @@ public async Task Login_WithEmptyJsonObject_ShouldNotAuthenticate() [Fact] public async Task Login_WithJsonPropertyNamesUsingDifferentCasing_ShouldAuthenticate() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"json-casing-{Guid.NewGuid():N}"); @@ -1270,7 +1260,7 @@ public async Task Login_WithJsonPropertyNamesUsingDifferentCasing_ShouldAuthenti ["SECRET"] = user.Secret }); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, LoginEndpoint, content); @@ -1305,7 +1295,7 @@ public async Task Login_WithUnknownIdentifier_ShouldNotRevealWhetherUserExists() { identifier = $"unknown-{Guid.NewGuid():N}", secret = "wrong-password" - }); + }, cancellationToken: TestContext.Current.CancellationToken); var existingResponse = await existingUserClient.PostAsJsonAsync( "/auth/login", @@ -1313,7 +1303,7 @@ public async Task Login_WithUnknownIdentifier_ShouldNotRevealWhetherUserExists() { identifier = ValidIdentifier, secret = "wrong-password" - }); + }, cancellationToken: TestContext.Current.CancellationToken); unknownResponse.StatusCode.Should().Be(existingResponse.StatusCode); @@ -1329,7 +1319,7 @@ public async Task Login_WithUnknownIdentifier_ShouldNotRevealWhetherUserExists() [Fact] public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAsWrongPassword() { - var existingUser = await _factory.CreateLoginUserAsync(); + var existingUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var unknownClient = CreateClient( $"unknown-{Guid.NewGuid():N}"); @@ -1337,7 +1327,7 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAs using var existingClient = CreateClient( $"existing-{Guid.NewGuid():N}"); - var unknownResponse = await unknownClient.PostAsJsonAsync( + var unknownResponse = await CsrfTestClient.PostJsonAsync(unknownClient, "/auth/try-login", new { @@ -1345,7 +1335,7 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAs secret = "wrong-password" }); - var existingResponse = await existingClient.PostAsJsonAsync( + var existingResponse = await CsrfTestClient.PostJsonAsync(existingClient, "/auth/try-login", new { @@ -1357,10 +1347,10 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAs existingResponse.StatusCode.Should().Be(HttpStatusCode.OK); var unknownResult = - await unknownResponse.Content.ReadFromJsonAsync(); + await unknownResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); var existingResult = - await existingResponse.Content.ReadFromJsonAsync(); + await existingResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); unknownResult.Should().NotBeNull(); existingResult.Should().NotBeNull(); @@ -1384,7 +1374,7 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt() using var client = CreateClient( $"unknown-receipt-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -1395,7 +1385,7 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt() response.StatusCode.Should().Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -1411,12 +1401,12 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt() [Fact] public async Task TryLogin_WithWrongPassword_ShouldNotIssuePreviewReceipt() { - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"wrong-password-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/try-login", new { @@ -1427,7 +1417,7 @@ public async Task TryLogin_WithWrongPassword_ShouldNotIssuePreviewReceipt() response.StatusCode.Should().Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -1452,7 +1442,7 @@ public async Task Login_WithUnknownIdentifier_ShouldNotReturnAuthenticationCrede { identifier = $"unknown-{Guid.NewGuid():N}", secret = ValidSecret - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.Headers .TryGetValues("Set-Cookie", out _) @@ -1469,7 +1459,7 @@ public async Task Login_FailuresOutsideFailureWindow_ShouldNotAccumulateTowardLo { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"failure-window-{Guid.NewGuid():N}"); @@ -1519,7 +1509,7 @@ public async Task Login_BeforeLockoutExpires_ShouldRemainLocked() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"active-lockout-{Guid.NewGuid():N}"); @@ -1552,7 +1542,7 @@ public async Task Login_AtLockoutExpirationBoundary_ShouldAllowCorrectCredential { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"lockout-boundary-{Guid.NewGuid():N}"); @@ -1587,7 +1577,7 @@ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockou { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"failure-window-inside-{Guid.NewGuid():N}"); @@ -1620,7 +1610,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"lockout-no-extension-{Guid.NewGuid():N}"); @@ -1679,7 +1669,7 @@ public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client1 = CreateClient( $"concurrent-failure-1-{Guid.NewGuid():N}"); @@ -1720,7 +1710,7 @@ public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUs { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"concurrent-success-1-{Guid.NewGuid():N}"; var device2 = $"concurrent-success-2-{Guid.NewGuid():N}"; @@ -1753,7 +1743,7 @@ public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUs { PageNumber = 1, PageSize = 10 - }), + }, cancellationToken: TestContext.Current.CancellationToken), authenticatedClient2.PostAsJsonAsync( "/auth/me/sessions/chains", @@ -1761,7 +1751,7 @@ public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUs { PageNumber = 1, PageSize = 10 - })); + }, cancellationToken: TestContext.Current.CancellationToken)); sessionResponses.Should().OnlyContain( x => x.StatusCode == HttpStatusCode.OK); @@ -1769,12 +1759,12 @@ public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUs var result1 = await sessionResponses[0] .Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); var result2 = await sessionResponses[1] .Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); result1.Should().NotBeNull(); result2.Should().NotBeNull(); @@ -1795,7 +1785,7 @@ public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptTo { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"preview-concurrent-{Guid.NewGuid():N}"; @@ -1852,8 +1842,7 @@ public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptTo } - private HttpClient CreateClient( - string deviceId = "test-device-1234567890123456") + private HttpClient CreateClient(string deviceId = "test-device-1234567890123456") { var client = _factory.CreateClient( new WebApplicationFactoryClientOptions @@ -1875,7 +1864,7 @@ private HttpClient CreateClient( private static Task LoginAsync(HttpClient client, string identifier, string secret) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, LoginEndpoint, new { @@ -1886,7 +1875,7 @@ private static Task LoginAsync(HttpClient client, string id private static async Task TryLoginAsync(HttpClient client, string identifier, string secret) { - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "auth/try-login", new LoginRequest { @@ -1905,7 +1894,7 @@ private static async Task TryLoginAsync(HttpClient client, strin private static Task LoginWithPreviewReceiptAsync(HttpClient client, string identifier, string secret, string previewReceipt) { - return client.PostAsJsonAsync("/auth/login", new + return CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier, secret, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs index 2b34bdc5..1f18607e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs @@ -23,7 +23,7 @@ public async Task LogoutDeviceAdmin_WithoutAuthentication_ShouldReturnUnauthoriz { _factory.Clock.Reset(); - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var targetClient = CreateClient( $"admin-logout-device-target-{Guid.NewGuid():N}"); @@ -44,7 +44,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -54,7 +54,7 @@ await LoginAsync( using var anonymousClient = CreateClient( $"admin-logout-device-anonymous-{Guid.NewGuid():N}"); - var response = await anonymousClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(anonymousClient, $"/auth/admin/users/{target.UserKey.Value}/logout-device", new LogoutDeviceRequest { @@ -76,8 +76,8 @@ public async Task LogoutDeviceAdmin_WithoutRequiredPermission_ShouldReturnForbid { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"admin-logout-device-unprivileged-{Guid.NewGuid():N}"); @@ -112,14 +112,14 @@ await LoginAsync( .Be(HttpStatusCode.OK); var targetChains = await targetChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetChains.Should().NotBeNull(); var targetChain = targetChains!.Items .Single(x => x.IsCurrentDevice); - var response = await actorClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/logout-device", new LogoutDeviceRequest { @@ -147,7 +147,7 @@ public async Task LogoutAllAdmin_WithoutAuthentication_ShouldReturnUnauthorized( { _factory.Clock.Reset(); - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var targetClient = CreateClient( $"admin-logout-all-target-{Guid.NewGuid():N}"); @@ -165,7 +165,7 @@ await LoginAsync( using var anonymousClient = CreateClient( $"admin-logout-all-anonymous-{Guid.NewGuid():N}"); - var response = await anonymousClient.PostAsync( + var response = await CsrfTestClient.PostAsync(anonymousClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -185,8 +185,8 @@ public async Task LogoutAllAdmin_WithoutRequiredPermission_ShouldReturnForbidden { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"admin-logout-all-unprivileged-{Guid.NewGuid():N}"); @@ -214,7 +214,7 @@ await LoginAsync( "Cookie", targetCookie); - var response = await actorClient.PostAsync( + var response = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -238,14 +238,14 @@ public async Task LogoutDeviceAdmin_WithPermission_ShouldInvalidateTargetDeviceS { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutDeviceAdmin - ]); + ], TestContext.Current.CancellationToken); var adminDevice = $"logout-admin-{Guid.NewGuid():N}"; @@ -289,7 +289,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var targetChains = await targetChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetChains.Should().NotBeNull(); @@ -299,7 +299,7 @@ await LoginAsync( // // Admin logs out target device. // - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-device", new LogoutDeviceRequest { @@ -333,14 +333,14 @@ public async Task LogoutDeviceAdmin_ShouldNotInvalidateTargetUsersOtherDevice() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutDeviceAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-admin-{Guid.NewGuid():N}"); @@ -391,7 +391,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -401,7 +401,7 @@ await LoginAsync( // // Admin terminates only device 1. // - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-device", new LogoutDeviceRequest { @@ -438,15 +438,15 @@ public async Task LogoutDeviceAdmin_ShouldNotAllowChainFromDifferentTargetUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var targetA = await _factory.CreateLoginUserAsync(); - var targetB = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetA = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetB = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutDeviceAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-admin-{Guid.NewGuid():N}"); @@ -487,9 +487,7 @@ await LoginAsync( "Cookie", targetBCookie); - // // Obtain B's chain. - // var targetBChainsResponse = await GetChainsAsync(targetBClient); @@ -497,17 +495,15 @@ await LoginAsync( .Be(HttpStatusCode.OK); var targetBChains = await targetBChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetBChains.Should().NotBeNull(); var targetBChain = targetBChains!.Items .Single(x => x.IsCurrentDevice); - // // URL says target A, but supplied chain belongs to B. - // - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{targetA.UserKey.Value}/logout-device", new LogoutDeviceRequest { @@ -518,24 +514,18 @@ await LoginAsync( HttpStatusCode.Forbidden, HttpStatusCode.NotFound); - // // Most important assertion: // B's session must NOT have been mutated. - // (await GetChainsAsync(targetBClient)) .StatusCode.Should() .Be(HttpStatusCode.OK); - // // A must also remain untouched. - // (await GetChainsAsync(targetAClient)) .StatusCode.Should() .Be(HttpStatusCode.OK); - // // Admin remains authenticated. - // (await GetChainsAsync(adminClient)) .StatusCode.Should() .Be(HttpStatusCode.OK); @@ -546,14 +536,14 @@ public async Task LogoutOthersAdmin_ShouldLogoutAllTargetDevicesExceptSpecifiedC { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutOthersAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient($"logout-others-admin-{Guid.NewGuid():N}"); @@ -580,13 +570,13 @@ await _factory.GrantPermissionsAsync( chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var chains = await chainsResponse.Content.ReadFromJsonAsync>(); + var chains = await chainsResponse.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); var currentChain = chains!.Items.Single(x => x.IsCurrentDevice); - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { @@ -609,8 +599,8 @@ public async Task LogoutOthersAdmin_WithoutRequiredPermission_ShouldReturnForbid { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"logout-others-actor-{Guid.NewGuid():N}"); @@ -647,14 +637,14 @@ await LoginAsync( await GetChainsAsync(targetClient1); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); var currentChain = chains!.Items .Single(x => x.IsCurrentDevice); - var response = await actorClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { @@ -685,15 +675,15 @@ public async Task LogoutOthersAdmin_ShouldRejectCurrentChainFromDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); - var otherUser = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var otherUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutOthersAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-others-admin-{Guid.NewGuid():N}"); @@ -743,7 +733,7 @@ await LoginAsync( await GetChainsAsync(otherClient); var otherChains = await otherChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); otherChains.Should().NotBeNull(); @@ -754,7 +744,7 @@ await LoginAsync( // URL target = target // CurrentChainId = otherUser's chain // - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { @@ -790,14 +780,14 @@ public async Task LogoutOthersAdmin_WithSingleTargetDevice_ShouldPreserveThatDev { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutOthersAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-others-admin-{Guid.NewGuid():N}"); @@ -832,14 +822,14 @@ await LoginAsync( .Be(HttpStatusCode.OK); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); var currentChain = chains!.Items .Single(x => x.IsCurrentDevice); - var response = await adminClient.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { @@ -870,14 +860,14 @@ public async Task LogoutOthersAdmin_WhenRepeated_ShouldRemainIdempotent() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutOthersAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-others-admin-{Guid.NewGuid():N}"); @@ -922,7 +912,7 @@ await LoginAsync( await GetChainsAsync(targetClient1); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -937,14 +927,14 @@ await LoginAsync( var url = $"/auth/admin/users/{target.UserKey.Value}/logout-others"; - var first = await adminClient.PostAsJsonAsync( + var first = await CsrfTestClient.PostJsonAsync(adminClient, url, request); first.StatusCode.Should() .Be(HttpStatusCode.OK); - var second = await adminClient.PostAsJsonAsync( + var second = await CsrfTestClient.PostJsonAsync(adminClient, url, request); @@ -978,14 +968,14 @@ public async Task LogoutAllAdmin_ShouldLogoutAllTargetDevices() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutAllAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-all-admin-{Guid.NewGuid():N}"); @@ -1057,7 +1047,7 @@ await LoginAsync( // // Admin logs out every device belonging to target. // - var response = await adminClient.PostAsync( + var response = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -1085,14 +1075,14 @@ public async Task LogoutAllAdmin_ShouldNotInvalidateAdminSession() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutAllAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-all-admin-{Guid.NewGuid():N}"); @@ -1120,7 +1110,7 @@ await LoginAsync( "Cookie", targetCookie); - var response = await adminClient.PostAsync( + var response = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -1148,8 +1138,8 @@ public async Task LogoutAllAdmin_WithoutRequiredPermission_ShouldReturnForbidden { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"logout-all-actor-{Guid.NewGuid():N}"); @@ -1205,7 +1195,7 @@ await LoginAsync( // Actor is authenticated but does NOT have // flows.logoutall.admin. // - var response = await actorClient.PostAsync( + var response = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -1236,14 +1226,14 @@ public async Task LogoutAllAdmin_WhenRepeated_ShouldRemainIdempotent() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutAllAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-all-admin-{Guid.NewGuid():N}"); @@ -1284,26 +1274,23 @@ await LoginAsync( "Cookie", targetCookie2); - var url = - $"/auth/admin/users/{target.UserKey.Value}/logout-all"; + var url = $"/auth/admin/users/{target.UserKey.Value}/logout-all"; - var first = await adminClient.PostAsync( + var first = await CsrfTestClient.PostAsync(adminClient, url, null); first.StatusCode.Should() .Be(HttpStatusCode.OK); - var second = await adminClient.PostAsync( + var second = await CsrfTestClient.PostAsync(adminClient, url, null); second.StatusCode.Should() .Be(HttpStatusCode.OK); - // // Sessions must remain logged out. - // (await GetChainsAsync(targetClient1)) .StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -1312,10 +1299,7 @@ await LoginAsync( .StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - // - // Repeating the operation must not affect - // the administrative actor. - // + // Repeating the operation must not affect the administrative actor. (await GetChainsAsync(adminClient)) .StatusCode.Should() .Be(HttpStatusCode.OK); @@ -1326,15 +1310,15 @@ public async Task LogoutAllAdmin_ShouldNotAffectUnrelatedUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); - var unrelated = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelated = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Flows.LogoutAllAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"logout-all-admin-{Guid.NewGuid():N}"); @@ -1375,7 +1359,7 @@ await LoginAsync( "Cookie", unrelatedCookie); - var response = await adminClient.PostAsync( + var response = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/logout-all", null); @@ -1431,7 +1415,7 @@ private HttpClient CreateClient(string deviceId) private static async Task LoginAsync(HttpClient client, string identifier, string secret) { - return await client.PostAsJsonAsync( + return await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -1442,7 +1426,7 @@ private static async Task LoginAsync(HttpClient client, str private static async Task GetChainsAsync(HttpClient client) { - return await client.PostAsJsonAsync( + return await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs index ba1b5f25..f1b757ed 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs @@ -23,7 +23,7 @@ public async Task Logout_WithAuthenticatedSession_ShouldSucceed() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"logout-basic-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -39,7 +39,7 @@ public async Task Logout_WithAuthenticatedSession_ShouldSucceed() client.DefaultRequestHeaders.Add("Cookie", cookie); - var logoutResponse = await client.PostAsync( + var logoutResponse = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -51,7 +51,7 @@ public async Task Logout_ShouldDeleteSessionCookie() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"logout-cookie-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -65,7 +65,7 @@ public async Task Logout_ShouldDeleteSessionCookie() client.DefaultRequestHeaders.Add("Cookie", cookie); - var logoutResponse = await client.PostAsync( + var logoutResponse = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -93,7 +93,7 @@ public async Task Logout_ShouldInvalidateSessionOnServer() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"logout-invalidation-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -110,10 +110,9 @@ public async Task Logout_ShouldInvalidateSessionOnServer() var beforeLogout = await GetChainsAsync(client); beforeLogout.StatusCode.Should().Be(HttpStatusCode.OK); - var logoutResponse = await client.PostAsync("/auth/logout", null); + var logoutResponse = await CsrfTestClient.PostAsync(client, "/auth/logout", null); logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); - // // IMPORTANT: // Deliberately keep sending the old cookie. // @@ -121,7 +120,6 @@ public async Task Logout_ShouldInvalidateSessionOnServer() // magically remove our manually supplied Cookie header. // // Therefore this proves server-side invalidation. - // var afterLogout = await GetChainsAsync(client); @@ -133,7 +131,7 @@ public async Task Logout_OldSessionCookie_ShouldNotAuthenticateFromNewClient() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"logout-replay-{Guid.NewGuid():N}"; using var originalClient = CreateClient(deviceId); @@ -149,7 +147,7 @@ public async Task Logout_OldSessionCookie_ShouldNotAuthenticateFromNewClient() "Cookie", cookie); - var logoutResponse = await originalClient.PostAsync( + var logoutResponse = await CsrfTestClient.PostAsync(originalClient, "/auth/logout", null); @@ -177,7 +175,7 @@ public async Task Logout_ShouldNotInvalidateOtherDeviceSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-device-1-{Guid.NewGuid():N}"; var device2 = $"logout-device-2-{Guid.NewGuid():N}"; @@ -215,7 +213,7 @@ public async Task Logout_ShouldNotInvalidateOtherDeviceSession() before1.StatusCode.Should().Be(HttpStatusCode.OK); before2.StatusCode.Should().Be(HttpStatusCode.OK); - var logout = await client1.PostAsync( + var logout = await CsrfTestClient.PostAsync(client1, "/auth/logout", null); @@ -238,8 +236,8 @@ public async Task Logout_ShouldNotInvalidateAnotherUsersSession() { _factory.Clock.Reset(); - var user1 = await _factory.CreateLoginUserAsync(); - var user2 = await _factory.CreateLoginUserAsync(); + var user1 = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var user2 = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client1 = CreateClient( $"logout-user-1-{Guid.NewGuid():N}"); @@ -268,7 +266,7 @@ public async Task Logout_ShouldNotInvalidateAnotherUsersSession() "Cookie", cookie2); - var logout = await client1.PostAsync( + var logout = await CsrfTestClient.PostAsync(client1, "/auth/logout", null); @@ -291,7 +289,7 @@ public async Task Logout_ShouldDetachActiveSessionFromCurrentChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-detach-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -307,11 +305,11 @@ public async Task Logout_ShouldDetachActiveSessionFromCurrentChain() { PageNumber = 1, PageSize = 50 - }); + }, cancellationToken: TestContext.Current.CancellationToken); beforeResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var before = await beforeResponse.Content.ReadFromJsonAsync>(); + var before = await beforeResponse.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -323,11 +321,11 @@ public async Task Logout_ShouldDetachActiveSessionFromCurrentChain() var chainId = currentBefore.ChainId; var sessionId = currentBefore.ActiveSessionId; - var logout = await client.PostAsync("/auth/logout", null); + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); logout.StatusCode.Should().Be(HttpStatusCode.Found); - var oldSessionResponse = await client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + var oldSessionResponse = await client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest(), cancellationToken: TestContext.Current.CancellationToken); oldSessionResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -343,11 +341,11 @@ public async Task Logout_ShouldDetachActiveSessionFromCurrentChain() { PageNumber = 1, PageSize = 50 - }); + }, cancellationToken: TestContext.Current.CancellationToken); afterResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var after = await afterResponse.Content.ReadFromJsonAsync>(); + var after = await afterResponse.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); after.Should().NotBeNull(); var currentAfter = after!.Items.Single(x => x.IsCurrentDevice); @@ -365,7 +363,7 @@ public async Task Logout_OldSession_ShouldRemainInvalidAfterLoginAgain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-old-session-{Guid.NewGuid():N}"; using var firstClient = CreateClient(device); @@ -375,7 +373,7 @@ public async Task Logout_OldSession_ShouldRemainInvalidAfterLoginAgain() firstClient.DefaultRequestHeaders.Add("Cookie", oldCookie); - var logout = await firstClient.PostAsync("/auth/logout", null); + var logout = await CsrfTestClient.PostAsync(firstClient, "/auth/logout", null); logout.StatusCode.Should().Be(HttpStatusCode.Found); @@ -389,14 +387,14 @@ public async Task Logout_OldSession_ShouldRemainInvalidAfterLoginAgain() secondClient.DefaultRequestHeaders.Add("Cookie", newCookie); - var newSessionResponse = await secondClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + var newSessionResponse = await secondClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest(), cancellationToken: TestContext.Current.CancellationToken); newSessionResponse.StatusCode.Should().Be(HttpStatusCode.OK); using var replayClient = CreateClient(device); replayClient.DefaultRequestHeaders.Add("Cookie", oldCookie); - var replayResponse = await replayClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + var replayResponse = await replayClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest(), cancellationToken: TestContext.Current.CancellationToken); replayResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } @@ -405,7 +403,7 @@ public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-device-1-{Guid.NewGuid():N}"; var device2 = $"logout-device-2-{Guid.NewGuid():N}"; @@ -429,7 +427,7 @@ public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() client1.DefaultRequestHeaders.Add("Cookie", cookie1); client2.DefaultRequestHeaders.Add("Cookie", cookie2); - var beforeResponse = await client2.PostAsJsonAsync( + var beforeResponse = await CsrfTestClient.PostJsonAsync(client2, "/auth/me/sessions/chains", new PageRequest { @@ -440,7 +438,7 @@ public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() beforeResponse.StatusCode.Should().Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -451,17 +449,17 @@ public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() device2SessionId.Should().NotBeNull(); - var logout = await client1.PostAsync("/auth/logout", null); + var logout = await CsrfTestClient.PostAsync(client1, "/auth/logout", null); logout.StatusCode.Should().Be(HttpStatusCode.Found); - var device1Result = await client1.PostAsJsonAsync( + var device1Result = await CsrfTestClient.PostJsonAsync(client1, "/auth/me/sessions/chains", new PageRequest()); device1Result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - var device2Result = await client2.PostAsJsonAsync( + var device2Result = await CsrfTestClient.PostJsonAsync(client2, "/auth/me/sessions/chains", new PageRequest { @@ -471,7 +469,7 @@ public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() device2Result.StatusCode.Should().Be(HttpStatusCode.OK); - var after = await device2Result.Content.ReadFromJsonAsync>(); + var after = await device2Result.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -487,7 +485,7 @@ public async Task Logout_ShouldNotRevokeDeviceChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-not-revoke-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -500,7 +498,7 @@ public async Task Logout_ShouldNotRevokeDeviceChain() var cookie = GetSessionCookie(login); client.DefaultRequestHeaders.Add("Cookie", cookie); - var beforeResponse = await client.PostAsJsonAsync( + var beforeResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { @@ -509,14 +507,15 @@ public async Task Logout_ShouldNotRevokeDeviceChain() }); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); before.Should().NotBeNull(); var chainBefore = before!.Items.Single(x => x.IsCurrentDevice); var chainId = chainBefore.ChainId; - await client.PostAsync("/auth/logout", null); + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); + logout.StatusCode.Should().Be(HttpStatusCode.Found); using var reloginClient = CreateClient(device); @@ -531,7 +530,7 @@ public async Task Logout_ShouldNotRevokeDeviceChain() "Cookie", newCookie); - var afterResponse = await reloginClient.PostAsJsonAsync( + var afterResponse = await CsrfTestClient.PostJsonAsync(reloginClient, "/auth/me/sessions/chains", new PageRequest { @@ -540,7 +539,7 @@ public async Task Logout_ShouldNotRevokeDeviceChain() }); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -559,11 +558,11 @@ public async Task Logout_WithoutAuthentication_ShouldBeSafeAndIdempotent() using var client = CreateClient( $"logout-anonymous-{Guid.NewGuid():N}"); - var first = await client.PostAsync( + var first = await CsrfTestClient.PostAsync(client, "/auth/logout", null); - var second = await client.PostAsync( + var second = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -576,7 +575,7 @@ public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainIdempotent() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-idempotent-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -594,19 +593,19 @@ public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainIdempotent() "Cookie", cookie); - var firstLogout = await client.PostAsync( + var firstLogout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); firstLogout.StatusCode.Should().Be(HttpStatusCode.Found); - var secondLogout = await client.PostAsync( + var secondLogout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); secondLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); - var authenticatedRequest = await client.PostAsJsonAsync( + var authenticatedRequest = await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest()); @@ -619,7 +618,7 @@ public async Task Logout_WithInvalidSessionCookie_ShouldNotAffectValidSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var validDevice = $"logout-valid-{Guid.NewGuid():N}"; @@ -650,7 +649,7 @@ public async Task Logout_WithInvalidSessionCookie_ShouldNotAffectValidSession() "Cookie", $"uas={Guid.NewGuid():N}"); - var invalidLogout = await invalidClient.PostAsync( + var invalidLogout = await CsrfTestClient.PostAsync(invalidClient, "/auth/logout", null); @@ -659,7 +658,7 @@ public async Task Logout_WithInvalidSessionCookie_ShouldNotAffectValidSession() // // Existing legitimate session must be untouched. // - var validSession = await validClient.PostAsJsonAsync( + var validSession = await CsrfTestClient.PostJsonAsync(validClient, "/auth/me/sessions/chains", new PageRequest { @@ -670,7 +669,7 @@ public async Task Logout_WithInvalidSessionCookie_ShouldNotAffectValidSession() validSession.StatusCode.Should().Be(HttpStatusCode.OK); var chains = await validSession.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -685,7 +684,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-stale-{Guid.NewGuid():N}"; // @@ -706,7 +705,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() "Cookie", oldCookie); - var firstLogout = await firstClient.PostAsync( + var firstLogout = await CsrfTestClient.PostAsync(firstClient, "/auth/logout", null); @@ -732,7 +731,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() "Cookie", newCookie); - var beforeReplay = await currentClient.PostAsJsonAsync( + var beforeReplay = await CsrfTestClient.PostJsonAsync(currentClient, "/auth/me/sessions/chains", new PageRequest { @@ -743,7 +742,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() beforeReplay.StatusCode.Should().Be(HttpStatusCode.OK); var beforeChains = await beforeReplay.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); beforeChains.Should().NotBeNull(); @@ -764,7 +763,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() "Cookie", oldCookie); - var staleLogout = await staleClient.PostAsync( + var staleLogout = await CsrfTestClient.PostAsync(staleClient, "/auth/logout", null); @@ -773,7 +772,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() // // Session B MUST still be valid. // - var afterReplay = await currentClient.PostAsJsonAsync( + var afterReplay = await CsrfTestClient.PostJsonAsync(currentClient, "/auth/me/sessions/chains", new PageRequest { @@ -784,7 +783,7 @@ public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() afterReplay.StatusCode.Should().Be(HttpStatusCode.OK); var afterChains = await afterReplay.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); afterChains.Should().NotBeNull(); @@ -801,7 +800,7 @@ public async Task RepeatedLogoutLoginCycles_ShouldPreserveChainAndRotateSessions { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-cycle-{Guid.NewGuid():N}"; SessionChainId? expectedChainId = null; @@ -824,7 +823,7 @@ public async Task RepeatedLogoutLoginCycles_ShouldPreserveChainAndRotateSessions "Cookie", cookie); - var chainsResponse = await client.PostAsJsonAsync( + var chainsResponse = await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { @@ -835,7 +834,7 @@ public async Task RepeatedLogoutLoginCycles_ShouldPreserveChainAndRotateSessions chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -861,7 +860,7 @@ public async Task RepeatedLogoutLoginCycles_ShouldPreserveChainAndRotateSessions // if (i < 2) { - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -881,7 +880,7 @@ public async Task Logout_WithStaleCredential_ShouldNotAffectOtherDevice() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-stale-device-1-{Guid.NewGuid():N}"; @@ -889,9 +888,7 @@ public async Task Logout_WithStaleCredential_ShouldNotAffectOtherDevice() var device2 = $"logout-stale-device-2-{Guid.NewGuid():N}"; - // // Device 1 login. - // using var device1Client = CreateClient(device1); var login1 = await LoginAsync( @@ -905,9 +902,7 @@ public async Task Logout_WithStaleCredential_ShouldNotAffectOtherDevice() "Cookie", staleCookie); - // // Device 2 login. - // using var device2Client = CreateClient(device2); var login2 = await LoginAsync( @@ -922,7 +917,7 @@ public async Task Logout_WithStaleCredential_ShouldNotAffectOtherDevice() device2Cookie); var device2BeforeResponse = - await device2Client.PostAsJsonAsync( + await CsrfTestClient.PostJsonAsync(device2Client, "/auth/me/sessions/chains", new PageRequest { @@ -935,7 +930,7 @@ await device2Client.PostAsJsonAsync( .Be(HttpStatusCode.OK); var before = await device2BeforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -947,7 +942,7 @@ await device2Client.PostAsJsonAsync( device2SessionId.Should().NotBeNull(); - var logout1 = await device1Client.PostAsync("/auth/logout", null); + var logout1 = await CsrfTestClient.PostAsync(device1Client, "/auth/logout", null); logout1.StatusCode.Should().Be(HttpStatusCode.Found); @@ -955,12 +950,12 @@ await device2Client.PostAsJsonAsync( staleClient.DefaultRequestHeaders.Add("Cookie", staleCookie); - var staleLogout = await staleClient.PostAsync("/auth/logout", null); + var staleLogout = await CsrfTestClient.PostAsync(staleClient, "/auth/logout", null); staleLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); var device2AfterResponse = - await device2Client.PostAsJsonAsync( + await CsrfTestClient.PostJsonAsync(device2Client, "/auth/me/sessions/chains", new PageRequest { @@ -973,7 +968,7 @@ await device2Client.PostAsJsonAsync( .Be(HttpStatusCode.OK); var after = await device2AfterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -985,52 +980,12 @@ await device2Client.PostAsJsonAsync( device2After.IsRevoked.Should().BeFalse(); } - [Fact] - public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainSafe() - { - _factory.Clock.Reset(); - - var user = await _factory.CreateLoginUserAsync(); - var device = $"logout-idempotent-{Guid.NewGuid():N}"; - - using var client = CreateClient(device); - - var login = await LoginAsync( - client, - user.Identifier, - user.Secret); - - login.StatusCode.Should().Be(HttpStatusCode.Found); - - var cookie = GetSessionCookie(login); - - client.DefaultRequestHeaders.Add("Cookie", cookie); - - var firstLogout = await client.PostAsync("/auth/logout", null); - - firstLogout.StatusCode.Should().Be(HttpStatusCode.Found); - - var secondLogout = await client.PostAsync("/auth/logout", null); - - secondLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); - - var authenticatedRequest = await client.PostAsJsonAsync( - "/auth/me/sessions/chains", - new PageRequest - { - PageNumber = 1, - PageSize = 50 - }); - - authenticatedRequest.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - [Fact] public async Task Logout_ShouldNotReturnAuthenticationCredentials() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-no-credentials-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1048,7 +1003,7 @@ public async Task Logout_ShouldNotReturnAuthenticationCredentials() "Cookie", sessionCookie); - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -1087,7 +1042,7 @@ public async Task Logout_ShouldDeleteSessionCookieWithRootPath() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-cookie-path-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1105,7 +1060,7 @@ public async Task Logout_ShouldDeleteSessionCookieWithRootPath() "Cookie", sessionCookie); - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -1133,7 +1088,7 @@ public async Task Logout_ShouldNotReissueSessionCookieAfterServerSideInvalidatio { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-no-reissue-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1151,7 +1106,7 @@ public async Task Logout_ShouldNotReissueSessionCookieAfterServerSideInvalidatio "Cookie", oldCookie); - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -1203,7 +1158,7 @@ public async Task Logout_CookieDeletion_ShouldNotContainOriginalSessionCredentia { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-cookie-value-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1227,7 +1182,7 @@ public async Task Logout_CookieDeletion_ShouldNotContainOriginalSessionCredentia "Cookie", sessionCookie); - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null); @@ -1255,7 +1210,7 @@ public async Task ConcurrentLogout_WithSameSession_ShouldLeaveSessionInvalid() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-concurrent-same-{Guid.NewGuid():N}"; using var loginClient = CreateClient(device); @@ -1279,18 +1234,26 @@ public async Task ConcurrentLogout_WithSameSession_ShouldLeaveSessionInvalid() client2.DefaultRequestHeaders.Add("Cookie", cookie); var responses = await Task.WhenAll( - client1.PostAsync("/auth/logout", null), - client2.PostAsync("/auth/logout", null)); + CsrfTestClient.PostAsync(client1, "/auth/logout", null), + CsrfTestClient.PostAsync(client2, "/auth/logout", null)); - // - // Depending on where stale-session rejection occurs, - // one request may observe the session after the other revoked it. - // - // What must never happen is an infrastructure failure. - // - responses.Should().OnlyContain(x => - x.StatusCode == HttpStatusCode.Found || - x.StatusCode == HttpStatusCode.Unauthorized); + foreach (var response in responses) + { + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + body.Should().Contain("invalid_antiforgery_token"); + continue; + } + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Found, + HttpStatusCode.Unauthorized); + } + + responses.Should().Contain(x => + x.StatusCode == HttpStatusCode.Found); // // Final state is the actual security invariant. @@ -1312,7 +1275,7 @@ public async Task ConcurrentLogout_FromDifferentDevices_ShouldInvalidateBothSess { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-concurrent-device-1-{Guid.NewGuid():N}"; @@ -1351,8 +1314,8 @@ public async Task ConcurrentLogout_FromDifferentDevices_ShouldInvalidateBothSess cookie2); var logouts = await Task.WhenAll( - client1.PostAsync("/auth/logout", null), - client2.PostAsync("/auth/logout", null)); + CsrfTestClient.PostAsync(client1, "/auth/logout", null), + CsrfTestClient.PostAsync(client2, "/auth/logout", null)); logouts.Should().OnlyContain( x => x.StatusCode == HttpStatusCode.Found); @@ -1384,7 +1347,7 @@ public async Task ConcurrentRequests_WithSessionBeingLoggedOut_ShouldNeverLeaveC { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-concurrent-request-{Guid.NewGuid():N}"; @@ -1414,7 +1377,7 @@ public async Task ConcurrentRequests_WithSessionBeingLoggedOut_ShouldNeverLeaveC // Intentionally race an authenticated operation against logout. // var logoutTask = - logoutClient.PostAsync("/auth/logout", null); + CsrfTestClient.PostAsync(logoutClient, "/auth/logout", null); var authenticatedRequestTask = GetChainsAsync(requestClient); @@ -1457,7 +1420,7 @@ public async Task ConcurrentLogoutAndRelogin_OnSameDevice_ShouldLeaveChainInCons { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-relogin-race-{Guid.NewGuid():N}"; @@ -1489,7 +1452,7 @@ public async Task ConcurrentLogoutAndRelogin_OnSameDevice_ShouldLeaveChainInCons oldCookie); var logoutTask = - logoutClient.PostAsync("/auth/logout", null); + CsrfTestClient.PostAsync(logoutClient, "/auth/logout", null); var loginTask = LoginAsync( @@ -1595,7 +1558,7 @@ public async Task LogoutDeviceSelf_ShouldRevokeTargetDeviceSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-device-self-1-{Guid.NewGuid():N}"; var device2 = $"logout-device-self-2-{Guid.NewGuid():N}"; @@ -1617,7 +1580,7 @@ public async Task LogoutDeviceSelf_ShouldRevokeTargetDeviceSession() chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -1625,7 +1588,7 @@ public async Task LogoutDeviceSelf_ShouldRevokeTargetDeviceSession() !x.IsCurrentDevice && x.ActiveSessionId is not null); - var logoutResponse = await client1.PostAsJsonAsync( + var logoutResponse = await CsrfTestClient.PostJsonAsync(client1, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1648,7 +1611,7 @@ public async Task LogoutDeviceSelf_ShouldNotInvalidateCurrentDevice_WhenAnotherD { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-device-keep-{Guid.NewGuid():N}"; var device2 = $"logout-device-target-{Guid.NewGuid():N}"; @@ -1668,7 +1631,7 @@ public async Task LogoutDeviceSelf_ShouldNotInvalidateCurrentDevice_WhenAnotherD var chainsResponse = await GetChainsAsync(client1); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -1676,7 +1639,7 @@ public async Task LogoutDeviceSelf_ShouldNotInvalidateCurrentDevice_WhenAnotherD !x.IsCurrentDevice && x.ActiveSessionId is not null); - var logout = await client1.PostAsJsonAsync( + var logout = await CsrfTestClient.PostJsonAsync(client1, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1716,7 +1679,7 @@ public async Task LogoutDeviceSelf_ShouldDetachActiveSessionWithoutRevokingChain { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-device-observer-{Guid.NewGuid():N}"; var device2 = $"logout-device-revoked-{Guid.NewGuid():N}"; @@ -1733,7 +1696,7 @@ public async Task LogoutDeviceSelf_ShouldDetachActiveSessionWithoutRevokingChain var beforeResponse = await GetChainsAsync(client1); - var before = await beforeResponse.Content.ReadFromJsonAsync>(); + var before = await beforeResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -1741,7 +1704,7 @@ public async Task LogoutDeviceSelf_ShouldDetachActiveSessionWithoutRevokingChain !x.IsCurrentDevice && x.ActiveSessionId is not null); - var logout = await client1.PostAsJsonAsync( + var logout = await CsrfTestClient.PostJsonAsync(client1, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1754,7 +1717,7 @@ public async Task LogoutDeviceSelf_ShouldDetachActiveSessionWithoutRevokingChain afterResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var after = await afterResponse.Content.ReadFromJsonAsync>(); + var after = await afterResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -1773,7 +1736,7 @@ public async Task LogoutDeviceSelf_WithoutAuthentication_ShouldReturnUnauthorize using var client = CreateClient( $"logout-device-anonymous-{Guid.NewGuid():N}"); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1788,8 +1751,8 @@ public async Task LogoutDeviceSelf_ShouldNotAllowUserToLogoutAnotherUsersDevice( { _factory.Clock.Reset(); - var attacker = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var attacker = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var attackerDevice = $"logout-device-attacker-{Guid.NewGuid():N}"; @@ -1830,7 +1793,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var victimChains = await victimChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); victimChains.Should().NotBeNull(); @@ -1840,7 +1803,7 @@ await LoginAsync( // // Attacker tries to target victim's ChainId. // - var attack = await attackerClient.PostAsJsonAsync( + var attack = await CsrfTestClient.PostJsonAsync(attackerClient, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1862,7 +1825,7 @@ public async Task LogoutDeviceSelf_ShouldAllowCurrentDeviceToLogoutItself() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-device-current-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1885,7 +1848,7 @@ public async Task LogoutDeviceSelf_ShouldAllowCurrentDeviceToLogoutItself() chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -1894,7 +1857,7 @@ public async Task LogoutDeviceSelf_ShouldAllowCurrentDeviceToLogoutItself() currentChain.ActiveSessionId.Should().NotBeNull(); - var logout = await client.PostAsJsonAsync( + var logout = await CsrfTestClient.PostJsonAsync(client, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1917,7 +1880,7 @@ public async Task LogoutDeviceSelf_WithUnknownChain_ShouldNotAffectCurrentSessio { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-device-unknown-{Guid.NewGuid():N}"; using var client = CreateClient(device); @@ -1933,7 +1896,7 @@ public async Task LogoutDeviceSelf_WithUnknownChain_ShouldNotAffectCurrentSessio "Cookie", cookie); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(client, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -1963,7 +1926,7 @@ public async Task LogoutDeviceSelf_AlreadyLoggedOutDevice_ShouldRemainSafe() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-device-repeat-actor-{Guid.NewGuid():N}"; @@ -1997,7 +1960,7 @@ await LoginAsync( var chainsResponse = await GetChainsAsync(actorClient); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -2005,7 +1968,7 @@ await LoginAsync( !x.IsCurrentDevice && x.ActiveSessionId is not null); - var first = await actorClient.PostAsJsonAsync( + var first = await CsrfTestClient.PostJsonAsync(actorClient, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -2017,7 +1980,7 @@ await LoginAsync( // // Same operation against an already detached chain. // - var second = await actorClient.PostAsJsonAsync( + var second = await CsrfTestClient.PostJsonAsync(actorClient, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -2058,7 +2021,7 @@ public async Task LogoutDeviceSelf_LoggedOutDevice_ShouldBeAbleToLoginAgain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-device-relogin-actor-{Guid.NewGuid():N}"; @@ -2093,7 +2056,7 @@ await LoginAsync( await GetChainsAsync(actorClient); var chains = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); chains.Should().NotBeNull(); @@ -2104,7 +2067,7 @@ await LoginAsync( var originalChainId = targetChain.ChainId; var originalSessionId = targetChain.ActiveSessionId; - var logout = await actorClient.PostAsJsonAsync( + var logout = await CsrfTestClient.PostJsonAsync(actorClient, "/auth/me/logout-device", new LogoutDeviceRequest { @@ -2159,7 +2122,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -2183,7 +2146,7 @@ public async Task LogoutOthersSelf_ShouldInvalidateAllOtherDeviceSessions() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-others-current-{Guid.NewGuid():N}"; var device2 = $"logout-others-other-1-{Guid.NewGuid():N}"; @@ -2206,9 +2169,7 @@ public async Task LogoutOthersSelf_ShouldInvalidateAllOtherDeviceSessions() client2.DefaultRequestHeaders.Add("Cookie", cookie2); client3.DefaultRequestHeaders.Add("Cookie", cookie3); - // // All three sessions must initially be usable. - // (await GetChainsAsync(client1)) .StatusCode.Should().Be(HttpStatusCode.OK); @@ -2218,24 +2179,18 @@ public async Task LogoutOthersSelf_ShouldInvalidateAllOtherDeviceSessions() (await GetChainsAsync(client3)) .StatusCode.Should().Be(HttpStatusCode.OK); - // // Device 1 logs out every OTHER device. - // - var logout = await client1.PostAsync( + var logout = await CsrfTestClient.PostAsync(client1, "/auth/me/logout-others", null); logout.StatusCode.Should().Be(HttpStatusCode.OK); - // // Actor survives. - // (await GetChainsAsync(client1)) .StatusCode.Should().Be(HttpStatusCode.OK); - // // Every other authority must be dead. - // (await GetChainsAsync(client2)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -2248,7 +2203,7 @@ public async Task LogoutOthersSelf_ShouldKeepCurrentSessionValid() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var currentDevice = $"logout-others-keep-current-{Guid.NewGuid():N}"; @@ -2289,7 +2244,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -2301,7 +2256,7 @@ await LoginAsync( currentSessionId.Should().NotBeNull(); - var logout = await currentClient.PostAsync( + var logout = await CsrfTestClient.PostAsync(currentClient, "/auth/me/logout-others", null); @@ -2317,7 +2272,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -2346,7 +2301,7 @@ public async Task LogoutOthersSelf_ShouldDetachOtherSessionsWithoutRevokingChain { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-others-observer-{Guid.NewGuid():N}"; @@ -2398,7 +2353,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -2414,7 +2369,7 @@ await LoginAsync( .Select(x => x.ChainId) .ToHashSet(); - var logout = await actorClient.PostAsync( + var logout = await CsrfTestClient.PostAsync(actorClient, "/auth/me/logout-others", null); @@ -2430,7 +2385,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -2465,8 +2420,8 @@ public async Task LogoutOthersSelf_ShouldNotAffectAnotherUsersSessions() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); // // Actor owns two devices so logout-others actually has work to do. @@ -2516,7 +2471,7 @@ await LoginAsync( (await GetChainsAsync(victimClient)) .StatusCode.Should().Be(HttpStatusCode.OK); - var logout = await actorClient1.PostAsync( + var logout = await CsrfTestClient.PostAsync(actorClient1, "/auth/me/logout-others", null); @@ -2547,7 +2502,7 @@ public async Task LogoutOthersSelf_WithoutAuthentication_ShouldReturnUnauthorize using var client = CreateClient( $"logout-others-anonymous-{Guid.NewGuid():N}"); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/me/logout-others", null); @@ -2560,7 +2515,7 @@ public async Task LogoutOthersSelf_WithNoOtherSessions_ShouldSucceed() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-others-single-{Guid.NewGuid():N}"; @@ -2584,7 +2539,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -2596,19 +2551,15 @@ await LoginAsync( sessionId.Should().NotBeNull(); - // // There is nothing to terminate. - // - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/me/logout-others", null); logout.StatusCode.Should() .Be(HttpStatusCode.OK); - // // No-op must not damage current authority. - // var afterResponse = await GetChainsAsync(client); @@ -2616,7 +2567,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -2635,7 +2586,7 @@ public async Task LogoutOthersSelf_RepeatedCall_ShouldRemainSafe() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"logout-others-repeat-actor-{Guid.NewGuid():N}"); @@ -2663,32 +2614,26 @@ await LoginAsync( "Cookie", targetCookie); - var first = await actorClient.PostAsync( + var first = await CsrfTestClient.PostAsync(actorClient, "/auth/me/logout-others", null); first.StatusCode.Should() .Be(HttpStatusCode.OK); - // // Nothing remains to logout except actor itself. - // - var second = await actorClient.PostAsync( + var second = await CsrfTestClient.PostAsync(actorClient, "/auth/me/logout-others", null); second.StatusCode.Should() .Be(HttpStatusCode.OK); - // // Actor survives repeated calls. - // (await GetChainsAsync(actorClient)) .StatusCode.Should().Be(HttpStatusCode.OK); - // // Previously invalidated authority stays invalid. - // (await GetChainsAsync(targetClient)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); } @@ -2698,7 +2643,7 @@ public async Task LogoutOthersSelf_OldCredentialsFromOtherDevices_ShouldRemainIn { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-others-replay-actor-{Guid.NewGuid():N}"; @@ -2729,7 +2674,7 @@ await LoginAsync( "Cookie", oldTargetCookie); - var logout = await actorClient.PostAsync( + var logout = await CsrfTestClient.PostAsync(actorClient, "/auth/me/logout-others", null); @@ -2765,7 +2710,7 @@ public async Task LogoutOthersSelf_LoggedOutDevices_ShouldBeAbleToLoginAgain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-others-relogin-actor-{Guid.NewGuid():N}"; @@ -2803,7 +2748,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -2817,7 +2762,7 @@ await LoginAsync( var originalSessionId = targetBefore.ActiveSessionId; - var logout = await actorClient.PostAsync( + var logout = await CsrfTestClient.PostAsync(actorClient, "/auth/me/logout-others", null); @@ -2868,7 +2813,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -2902,7 +2847,7 @@ public async Task ConcurrentLogoutOthersSelf_ShouldLeaveOnlyActorSessionUsable() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-others-concurrent-actor-{Guid.NewGuid():N}"; @@ -2947,11 +2892,11 @@ await LoginAsync( actorCookie); var responses = await Task.WhenAll( - request1.PostAsync( + CsrfTestClient.PostAsync(request1, "/auth/me/logout-others", null), - request2.PostAsync( + CsrfTestClient.PostAsync(request2, "/auth/me/logout-others", null)); @@ -3005,7 +2950,7 @@ public async Task LogoutAllSelf_ShouldInvalidateAllDeviceSessions() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-all-device-1-{Guid.NewGuid():N}"; var device2 = $"logout-all-device-2-{Guid.NewGuid():N}"; @@ -3028,9 +2973,7 @@ public async Task LogoutAllSelf_ShouldInvalidateAllDeviceSessions() client2.DefaultRequestHeaders.Add("Cookie", cookie2); client3.DefaultRequestHeaders.Add("Cookie", cookie3); - // // Sanity check: every authority is initially valid. - // (await GetChainsAsync(client1)) .StatusCode.Should().Be(HttpStatusCode.OK); @@ -3040,19 +2983,14 @@ public async Task LogoutAllSelf_ShouldInvalidateAllDeviceSessions() (await GetChainsAsync(client3)) .StatusCode.Should().Be(HttpStatusCode.OK); - // - // Device 1 logs out ALL sessions belonging to the user, - // including its own session. - // - var logout = await client1.PostAsync( + // Device 1 logs out ALL sessions belonging to the user, including its own session. + var logout = await CsrfTestClient.PostAsync(client1, "/auth/me/logout-all", null); logout.StatusCode.Should().Be(HttpStatusCode.OK); - // // Every previously issued authority must now be dead. - // (await GetChainsAsync(client1)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -3068,7 +3006,7 @@ public async Task LogoutAllSelf_ShouldInvalidateCallingSession() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device = $"logout-all-current-{Guid.NewGuid():N}"; @@ -3096,7 +3034,7 @@ await LoginAsync( // // Unlike logout-others, logout-all includes the actor. // - var logout = await client.PostAsync( + var logout = await CsrfTestClient.PostAsync(client, "/auth/me/logout-all", null); @@ -3132,7 +3070,7 @@ public async Task LogoutAllSelf_ShouldDetachSessionsWithoutRevokingChains() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-all-chain-1-{Guid.NewGuid():N}"; @@ -3174,7 +3112,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -3189,7 +3127,7 @@ await LoginAsync( x => x.ChainId, x => x.ActiveSessionId); - var logout = await client1.PostAsync( + var logout = await CsrfTestClient.PostAsync(client1, "/auth/me/logout-all", null); @@ -3233,7 +3171,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var after = await afterResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); after.Should().NotBeNull(); @@ -3272,8 +3210,8 @@ public async Task LogoutAllSelf_ShouldNotAffectAnotherUsersSessions() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient1 = CreateClient( $"logout-all-actor-1-{Guid.NewGuid():N}"); @@ -3320,26 +3258,22 @@ await LoginAsync( (await GetChainsAsync(victimClient)) .StatusCode.Should().Be(HttpStatusCode.OK); - var logout = await actorClient1.PostAsync( + var logout = await CsrfTestClient.PostAsync(actorClient1, "/auth/me/logout-all", null); logout.StatusCode.Should() .Be(HttpStatusCode.OK); - // // Both actor sessions must die. - // (await GetChainsAsync(actorClient1)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); (await GetChainsAsync(actorClient2)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); - // // Critical ownership invariant: // User B must remain completely unaffected. - // var victimVerification = await GetChainsAsync(victimClient); @@ -3355,7 +3289,7 @@ public async Task LogoutAllSelf_WithoutAuthentication_ShouldReturnUnauthorized() using var client = CreateClient( $"logout-all-anonymous-{Guid.NewGuid():N}"); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/me/logout-all", null); @@ -3368,7 +3302,7 @@ public async Task LogoutAllSelf_LoggedOutDevices_ShouldBeAbleToLoginAgain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var device1 = $"logout-all-relogin-1-{Guid.NewGuid():N}"; @@ -3403,7 +3337,7 @@ await LoginAsync( await GetChainsAsync(client1); var before = await beforeResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); before.Should().NotBeNull(); @@ -3415,7 +3349,7 @@ await LoginAsync( originalChains.Should().HaveCount(2); - var logout = await client1.PostAsync( + var logout = await CsrfTestClient.PostAsync(client1, "/auth/me/logout-all", null); @@ -3487,7 +3421,7 @@ public async Task ConcurrentLogoutAllSelf_ShouldConvergeToAllSessionsInvalid() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var actorDevice = $"logout-all-concurrent-actor-{Guid.NewGuid():N}"; @@ -3510,10 +3444,7 @@ await LoginAsync( user.Identifier, user.Secret)); - // - // Two independent HTTP requests start with the same - // authenticated actor authority. - // + // Two independent HTTP requests start with the same authenticated actor authority. using var request1 = CreateClient(actorDevice); using var request2 = CreateClient(actorDevice); @@ -3526,30 +3457,38 @@ await LoginAsync( actorCookie); var responses = await Task.WhenAll( - request1.PostAsync( + CsrfTestClient.PostAsync(request1, "/auth/me/logout-all", null), - request2.PostAsync( + CsrfTestClient.PostAsync(request2, "/auth/me/logout-all", null)); - // - // Depending on where authentication is linearized, - // the second request may observe either the pre-logout - // or post-logout authority. - // - responses.Should().OnlyContain(x => - x.StatusCode == HttpStatusCode.OK || - x.StatusCode == HttpStatusCode.Unauthorized); + foreach (var response in responses) + { + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var body = await response.Content + .ReadFromJsonAsync>(TestContext.Current.CancellationToken); + + body.Should().NotBeNull(); + body.Should().ContainKey("error") + .WhoseValue.Should().Be("invalid_antiforgery_token"); + + continue; + } + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.OK, + HttpStatusCode.Unauthorized); + } responses.Should().Contain(x => x.StatusCode == HttpStatusCode.OK); - // // Final state is the security invariant that matters: // no old authority may survive. - // using var actorVerification = CreateClient(actorDevice); @@ -3572,6 +3511,114 @@ await LoginAsync( x.StatusCode == HttpStatusCode.Unauthorized); } + [Fact] + public async Task Logout_AntiforgeryTokenMustUseAuthenticatedDeviceContext() + { + _factory.Clock.Reset(); + + var ct = TestContext.Current.CancellationToken; + var user = await _factory.CreateLoginUserAsync(ct: ct); + var deviceId = $"csrf-device-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + using var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var sessionCookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add("Cookie", sessionCookie); + + // First reproduce the old JS behavior: + // the antiforgery GET does not carry X-UDID. + client.DefaultRequestHeaders.Remove("X-UDID"); + + using var anonymousTokenResponse = await client.GetAsync( + "/auth/antiforgery/token", ct); + + anonymousTokenResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var anonymousToken = await anonymousTokenResponse.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + anonymousToken.Should().NotBeNull(); + + var antiforgeryCookie = GetAntiforgeryCookie( + anonymousTokenResponse); + + // The POST has the correct device, but its CSRF token + // was obtained in a different authentication context. + using var rejectedRequest = new HttpRequestMessage( + HttpMethod.Post, "/auth/logout"); + + rejectedRequest.Headers.Add("X-UDID", deviceId); + rejectedRequest.Headers.Add( + anonymousToken!.HeaderName, + anonymousToken.RequestToken); + rejectedRequest.Headers.Add( + "Cookie", + $"{sessionCookie}; {antiforgeryCookie}"); + rejectedRequest.Headers.Add( + "X-UAuth-Logout-Mode", "observe"); + + using var rejected = await client.SendAsync( + rejectedRequest, ct); + + rejected.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var rejectedBody = await rejected.Content.ReadAsStringAsync(ct); + rejectedBody.Should().Contain("invalid_antiforgery_token"); + + // Correct behavior: obtain the token using the same + // Device ID that will be used by the logout POST. + using var tokenRequest = new HttpRequestMessage( + HttpMethod.Get, "/auth/antiforgery/token"); + + tokenRequest.Headers.Add("X-UDID", deviceId); + tokenRequest.Headers.Add("Cookie", sessionCookie); + + using var tokenResponse = await client.SendAsync( + tokenRequest, ct); + + tokenResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = await tokenResponse.Content + .ReadFromJsonAsync( + cancellationToken: ct); + + token.Should().NotBeNull(); + + var validAntiforgeryCookie = GetAntiforgeryCookie( + tokenResponse); + + using var logoutRequest = new HttpRequestMessage( + HttpMethod.Post, "/auth/logout"); + + logoutRequest.Headers.Add("X-UDID", deviceId); + logoutRequest.Headers.Add( + token!.HeaderName, + token.RequestToken); + logoutRequest.Headers.Add( + "Cookie", + $"{sessionCookie}; {validAntiforgeryCookie}"); + logoutRequest.Headers.Add( + "X-UAuth-Logout-Mode", "observe"); + + using var logout = await client.SendAsync( + logoutRequest, ct); + + var logoutBody = await logout.Content.ReadAsStringAsync(ct); + + logout.StatusCode.Should().Be( + HttpStatusCode.OK, + logoutBody); + } + // ------------------------------------------------------------ // Helpers @@ -3594,7 +3641,7 @@ private HttpClient CreateClient(string deviceId) private static Task LoginAsync(HttpClient client, string identifier, string secret) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -3605,7 +3652,7 @@ private static Task LoginAsync(HttpClient client, string id private static Task GetChainsAsync(HttpClient client) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { @@ -3623,7 +3670,9 @@ private static string GetSessionCookie(HttpResponseMessage response) cookies.Should().NotBeNullOrEmpty(); - var cookie = cookies!.First(); + var cookie = cookies! + .Select(x => x.Split(';', 2)[0]) + .First(x => x.StartsWith("uas=", StringComparison.OrdinalIgnoreCase)); cookie.Should().NotBeNullOrWhiteSpace(); return cookie; @@ -3651,4 +3700,23 @@ private static string GetSessionCookie(HttpResponseMessage response) return firstSegment[(separator + 1)..]; } + + private sealed record AntiforgeryTokenResponse( + string RequestToken, + string HeaderName, + string FormFieldName); + + private static string GetAntiforgeryCookie( + HttpResponseMessage response) + { + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + return cookies! + .Select(x => x.Split(';', 2)[0]) + .First(x => x.TrimStart().StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs index ae851ccf..a73d90ef 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/PkceFlowIntegrationTests.cs @@ -31,7 +31,7 @@ public async Task Pkce_WithValidVerifierAndCredentials_ShouldAuthenticate() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -76,7 +76,7 @@ public async Task Pkce_AfterSuccessfulCompletion_ShouldRejectReplayOfAuthorizati _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -127,7 +127,7 @@ public async Task Pkce_WithInvalidVerifier_ShouldConsumeAuthorizationCode() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -181,7 +181,7 @@ public async Task Pkce_AfterAuthorizationCodeExpires_ShouldRejectCompletion() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -225,7 +225,7 @@ public async Task Pkce_WithValidProofButInvalidCredentials_ShouldNotAuthenticate _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -260,7 +260,7 @@ public async Task Pkce_WithInvalidProof_ShouldNotAuthenticateEvenWithValidCreden _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -379,7 +379,7 @@ public async Task Pkce_WithValidProofAndRepeatedInvalidCredentials_ShouldReachCr _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( @@ -557,7 +557,7 @@ private static Task CompleteAsync(HttpClient client, string returnUrl ?? string.Empty }; - return client.PostAsync( + return CsrfTestClient.PostAsync(client, CompleteEndpoint, new FormUrlEncodedContent(values)); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs new file mode 100644 index 00000000..b9d185a4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RateLimitingFlowTests +{ + [Fact] + public async Task RejectedLoginDoesNotLockAccount_AndRejectedLogoutDoesNotRevokeSession() + { + using var factory = AuthServerFactory.Create(o => + { + o.RateLimiting.Enabled = true; + o.RateLimiting.Login.PermitLimit = 2; + o.RateLimiting.Endpoints[UAuthEndpoint.Logout] = new() { PermitLimit = 1 }; + o.Login.MaxFailedAttempts = 1; + }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + HttpClient Client(string device) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { AllowAutoRedirect = false, HandleCookies = false }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", device); + return client; + } + using var first = Client("rate-limit-flow-first-123456"); + using var second = Client("rate-limit-flow-second-123456"); + foreach (var client in new[] { first, second }) + { + var login = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + Assert.True(login.Headers.TryGetValues("Set-Cookie", out var cookies)); + client.DefaultRequestHeaders.Add("Cookie", cookies!.First()); + } + var rejected = await CsrfTestClient.PostJsonAsync(first, "/auth/login", new { identifier = user.Identifier, secret = "wrong" }); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + using (var scope = factory.Services.CreateScope()) + { + var security = scope.ServiceProvider.GetRequiredService(); + var factor = await security.GetOrCreateFactorAsync(TenantKey.Single, user.UserKey, CredentialType.Password, TestContext.Current.CancellationToken); + Assert.Equal(0, factor.FailedAttempts); + } + Assert.NotEqual(HttpStatusCode.TooManyRequests, (await first.PostAsync("/auth/logout", null, TestContext.Current.CancellationToken)).StatusCode); + // Two validated sessions of the same actor share logout capacity. + var logout = await CsrfTestClient.PostAsync(second, "/auth/logout", null); + Assert.Equal(HttpStatusCode.TooManyRequests, logout.StatusCode); + Assert.False(logout.Headers.Contains("Set-Cookie")); + // The rejected logout did not revoke the second session. + var validation = await CsrfTestClient.PostAsync(second, "/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, validation.StatusCode); + var result = await validation.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result?.IsValid); + Assert.NotNull(result!.Snapshot); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs new file mode 100644 index 00000000..0ba5a65a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class RateLimitingProofFlowTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(2); + + [Fact] + public async Task PkceCompletion_RejectedRequestPreservesAuthorizationProof() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Login = new() { PermitLimit = 1, Window = Window }; }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + using var client = Client(factory); + static string Encode(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var verifier = Encode(RandomNumberGenerator.GetBytes(32)); + var authorize = await CsrfTestClient.PostAsync(client, "/auth/pkce/authorize", new FormUrlEncodedContent(new Dictionary + { ["code_challenge"] = Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), ["challenge_method"] = "S256", ["redirect_uri"] = "/callback" })); + Assert.Equal(HttpStatusCode.OK, authorize.StatusCode); + var proof = await authorize.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(proof); + // Malformed payload consumes request quota without supplying the valid authorization proof. + Assert.Equal(HttpStatusCode.BadRequest, (await CsrfTestClient.PostAsync(client, "/auth/pkce/complete", new FormUrlEncodedContent(new Dictionary()))).StatusCode); + Task Complete() => CsrfTestClient.PostAsync(client, "/auth/pkce/complete", new FormUrlEncodedContent(new Dictionary + { + ["authorization_code"] = proof.AuthorizationCode, + ["code_verifier"] = verifier, + ["Identifier"] = user.Identifier, + ["Secret"] = user.Secret, + ["return_url"] = "/callback" + })); + var rejected = await Complete(); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + var completed = await AfterReplenishment(Complete); + Assert.NotEqual(HttpStatusCode.TooManyRequests, completed.StatusCode); + Assert.True(completed.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task Refresh_RejectedRequestPreservesRefreshToken_ThenSameTokenRotates() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() { PermitLimit = 1, Window = Window }; }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + using var client = Client(factory); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", "BlazorWasm"); + var login = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + var cookies = Cookies(login); + var session = cookies.Split("; ").Single(c => c.StartsWith("uas=")); + var refresh = cookies.Split("; ").Single(c => c.StartsWith("uar="))[4..]; + using var scope = factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKey.Single); + var hash = scope.ServiceProvider.GetRequiredService().Hash(refresh); + var before = await store.FindByHashAsync(hash, TestContext.Current.CancellationToken); + Assert.NotNull(before); + client.DefaultRequestHeaders.Add("Cookie", session); + Assert.Equal(HttpStatusCode.Unauthorized, (await CsrfTestClient.PostAsync(client, "/auth/refresh", null)).StatusCode); + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + var rejected = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + Assert.Equal(before, await store.FindByHashAsync(hash, TestContext.Current.CancellationToken)); + var renewed = await AfterReplenishment(() => CsrfTestClient.PostAsync(client, "/auth/refresh", null)); + Assert.Equal(HttpStatusCode.NoContent, renewed.StatusCode); + Assert.True(renewed.Headers.Contains("Set-Cookie")); + Assert.False((await store.FindByHashAsync(hash, TestContext.Current.CancellationToken))!.IsActive(factory.Clock.UtcNow)); + } + + [Fact] + public async Task Recovery_RejectedRequestPreservesResetProof_ThenSameProofChangesPassword() + { + var notifier = new RecordingResetNotifier(); + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.CredentialResetCompleteAnonymous] = new() { PermitLimit = 1, Window = Window }; }, services => + { services.RemoveAll(); services.AddSingleton(notifier); }); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + using var client = Client(factory); + var begin = await CsrfTestClient.PostJsonAsync(client, "/auth/me/credentials/reset/begin", new BeginResetCredentialRequest + { Identifier = user.Identifier, CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token }); + Assert.Equal(HttpStatusCode.OK, begin.StatusCode); + Assert.NotNull(notifier.Last); + // Consume quota using an unknown account, never presenting the issued proof. + await CsrfTestClient.PostJsonAsync(client, "/auth/me/credentials/reset/complete", new CompleteResetCredentialRequest + { Identifier = "unknown-account", CredentialType = CredentialType.Password, ResetToken = "invalid", NewSecret = "Changed-password-123!" }); + var request = new CompleteResetCredentialRequest + { Identifier = user.Identifier, CredentialType = CredentialType.Password, ResetToken = notifier.Last.Token, NewSecret = "Changed-password-123!" }; + Assert.Equal(HttpStatusCode.TooManyRequests, (await CsrfTestClient.PostJsonAsync(client, "/auth/me/credentials/reset/complete", request)).StatusCode); + var completed = await AfterReplenishment(() => CsrfTestClient.PostJsonAsync(client, "/auth/me/credentials/reset/complete", request)); + Assert.Equal(HttpStatusCode.OK, completed.StatusCode); + var login = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier = user.Identifier, secret = request.NewSecret }); + Assert.True(login.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task AuthenticatedUsersOnSameConnectionIpHaveIndependentValidationBudgets() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() { PermitLimit = 1 }; }); + using var first = Client(factory); + using var second = Client(factory); + foreach (var client in new[] { first, second }) + { + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var login = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + client.DefaultRequestHeaders.Add("Cookie", Cookies(login)); + var validation = await CsrfTestClient.PostAsync(client, "/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, validation.StatusCode); + Assert.True((await validation.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken))!.IsValid); + Assert.Equal(HttpStatusCode.TooManyRequests, (await CsrfTestClient.PostAsync(client, "/auth/validate", null)).StatusCode); + } + } + + private static async Task AfterReplenishment(Func> send) + { + // Native limiter time is independent of the authentication test clock. Its heartbeat + // can replenish segments later than the nominal window, so wait on observable behavior. + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + while (true) + { + await Task.Delay(100); + var response = await send(); + if (response.StatusCode != HttpStatusCode.TooManyRequests || elapsed.Elapsed > TimeSpan.FromSeconds(10)) + return response; + response.Dispose(); + } + } + + private static HttpClient Client(AuthServerFactory factory) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", "rate-proof-device-1234567890"); + return client; + } + private static string Cookies(HttpResponseMessage response) + { + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies)); + return string.Join("; ", cookies!.Select(c => c.Split(';')[0])); + } + private sealed class RecordingResetNotifier : ICredentialResetNotifier + { + internal CredentialResetNotification? Last { get; private set; } + public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default) + { Last = notification; return Task.CompletedTask; } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs new file mode 100644 index 00000000..7fa8f5e0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs @@ -0,0 +1,393 @@ +using System.Net; +using System.Threading.RateLimiting; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RateLimitingTests +{ + [Fact] + public async Task LoginAliasesShareBudget_AndMaintenanceIsIndependent() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + foreach (var route in new[] { "/auth/try-login", "/auth/pkce/complete", "/auth/pkce/try-complete" }) + { + var response = await host.Post(route); + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + if (response.Headers.RetryAfter is not null) + Assert.True(response.Headers.RetryAfter.Delta > TimeSpan.Zero); + Assert.True(response.Headers.CacheControl?.NoStore); + Assert.Contains("rate_limited", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + foreach (var route in new[] { "/auth/validate", "/auth/refresh", "/auth/logout" }) + Assert.Equal(HttpStatusCode.NoContent, (await host.Post(route)).StatusCode); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public async Task HelperOptInAndOptOut(bool aspNet, bool enabled, bool throttled) + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, aspNet, enabled); + await host.Post("/auth/login"); + Assert.Equal(throttled ? HttpStatusCode.TooManyRequests : HttpStatusCode.NoContent, + (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task DefaultHelperAndRepeatedHelper_DoNotConsumeTwice() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 2, repeat: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task DisableAndEndpointOverride_DoNotDisableApplicationGlobalLimiter() + { + await using var host = await Host.Create(o => o.RateLimiting.Enabled = false, native: o => + { + o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetSlidingWindowLimiter("global", _ => new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 6 })); + o.RejectionStatusCode = 418; + }); + await host.Post("/auth/login"); + Assert.Equal(418, (int)(await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task ApplicationGroupPolicyWins_AndGenericNamesDoNotCollide() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, + native: o => o.AddFixedWindowLimiter("Sensitive", x => + { x.PermitLimit = 3; x.Window = TimeSpan.FromMinutes(1); }), applicationPolicy: true); + for (var i = 0; i < 3; i++) + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.ServiceUnavailable, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task EndpointOverrideAndAnonymousIpIsolation() + { + await using var host = await Host.Create(o => o.RateLimiting.Endpoints[UAuthEndpoint.Login] = new() { PermitLimit = 1 }); + await host.Post("/auth/login"); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login", forwarded: "203.0.113.9")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", ip: "192.0.2.2")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/try-login")).StatusCode); + } + + [Fact] + public async Task EveryMappedEndpointHasOneClassification() + { + await using var host = await Host.Create(); + var routes = ((IEndpointRouteBuilder)host.App).DataSources.SelectMany(s => s.Endpoints).ToArray(); + // Reviewed route/identity/group contract, independent of the runtime classification lookup. + const string expected = """ +/auth/admin/authorization/roles/create|CreateRole|Standard +/auth/admin/authorization/roles/query|QueryRoles|Standard +/auth/admin/authorization/roles/{roleId}/delete|DeleteRole|Standard +/auth/admin/authorization/roles/{roleId}/permissions|SetRolePermissions|Standard +/auth/admin/authorization/roles/{roleId}/rename|RenameRole|Standard +/auth/admin/authorization/users/{userKey}/roles/assign|AssignRole|Standard +/auth/admin/authorization/users/{userKey}/roles/get|GetUserRoles|Standard +/auth/admin/authorization/users/{userKey}/roles/remove|RemoveRole|Standard +/auth/admin/users/create|UserCreateAdmin|Standard +/auth/admin/users/get-by-keys|GetUsersByKeys|Standard +/auth/admin/users/query|QueryUsers|Standard +/auth/admin/users/{userKey}/credentials/add|CredentialAddAdmin|Standard +/auth/admin/users/{userKey}/credentials/change|CredentialChangeAdmin|Standard +/auth/admin/users/{userKey}/credentials/delete|CredentialDeleteAdmin|Standard +/auth/admin/users/{userKey}/credentials/reset/begin|CredentialResetBeginAdmin|Sensitive +/auth/admin/users/{userKey}/credentials/reset/complete|CredentialResetCompleteAdmin|Sensitive +/auth/admin/users/{userKey}/credentials/revoke|CredentialRevokeAdmin|Standard +/auth/admin/users/{userKey}/delete|UserDeleteAdmin|Standard +/auth/admin/users/{userKey}/identifiers/add|AddUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/delete|DeleteUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/get|GetUserIdentifiers|Standard +/auth/admin/users/{userKey}/identifiers/set-primary|SetPrimaryUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/unset-primary|UnsetPrimaryUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/update|UpdateUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/verify|VerifyUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/logout-all|LogoutAllAdmin|Standard +/auth/admin/users/{userKey}/logout-device|LogoutDeviceAdmin|Standard +/auth/admin/users/{userKey}/logout-others|LogoutOthersAdmin|Standard +/auth/admin/users/{userKey}/profile/create|CreateProfileAdmin|Standard +/auth/admin/users/{userKey}/profile/delete|DeleteProfileAdmin|Standard +/auth/admin/users/{userKey}/profile/get|ProfileGetAdmin|Standard +/auth/admin/users/{userKey}/profile/update|ProfileUpdateAdmin|Standard +/auth/admin/users/{userKey}/sessions/chains/{chainId}/revoke|RevokeUserChain|Standard +/auth/admin/users/{userKey}/sessions/chains/{chainId}|GetUserChainDetail|Standard +/auth/admin/users/{userKey}/sessions/chains|GetUserChains|Standard +/auth/admin/users/{userKey}/sessions/revoke-all|RevokeAllChains|Standard +/auth/admin/users/{userKey}/sessions/revoke-root|RevokeRoot|Standard +/auth/admin/users/{userKey}/sessions/{sessionId}/revoke|RevokeUserSession|Standard +/auth/admin/users/{userKey}/status|ChangeStatusAdmin|Standard +/auth/login|Login|Sensitive +/auth/logout|Logout|Frequent +/auth/me/authorization/check|AuthorizationCheck|Frequent +/auth/me/authorization/permissions/get|GetMyPermissions|Standard +/auth/me/authorization/roles/get|GetMyRoles|Standard +/auth/me/credentials/add|CredentialAddSelf|Sensitive +/auth/me/credentials/change|CredentialChangeSelf|Sensitive +/auth/me/credentials/reset/begin|CredentialResetBeginAnonymous|Sensitive +/auth/me/credentials/reset/complete|CredentialResetCompleteAnonymous|Sensitive +/auth/me/credentials/revoke|CredentialRevokeSelf|Standard +/auth/me/delete|UserDeleteSelf|Standard +/auth/me/identifiers/add|AddUserIdentifierSelf|Sensitive +/auth/me/identifiers/check-availability|CheckIdentifierAvailability|Sensitive +/auth/me/identifiers/delete|DeleteUserIdentifierSelf|Sensitive +/auth/me/identifiers/get|GetMyIdentifiers|Standard +/auth/me/identifiers/set-primary|SetPrimaryUserIdentifierSelf|Sensitive +/auth/me/identifiers/unset-primary|UnsetPrimaryUserIdentifierSelf|Sensitive +/auth/me/identifiers/update|UpdateUserIdentifierSelf|Sensitive +/auth/me/identifiers/verify|VerifyUserIdentifierSelf|Sensitive +/auth/me/logout-all|LogoutAllSelf|Standard +/auth/me/logout-device|LogoutDeviceSelf|Standard +/auth/me/logout-others|LogoutOthersSelf|Standard +/auth/me/profile/create|CreateProfileSelf|Standard +/auth/me/profile/delete|DeleteProfileSelf|Standard +/auth/me/profile/get|ProfileGetSelf|Standard +/auth/me/profile/update|ProfileUpdateSelf|Standard +/auth/me/sessions/chains/{chainId}/revoke|RevokeMyChain|Standard +/auth/me/sessions/chains/{chainId}|GetMyChainDetail|Standard +/auth/me/sessions/chains|GetMyChains|Standard +/auth/me/sessions/revoke-all|RevokeAllMyChains|Standard +/auth/me/sessions/revoke-others|RevokeOtherChains|Standard +/auth/me/status|ChangeStatusSelf|Standard +/auth/password-policy|PasswordPolicy|Standard +/auth/pkce/authorize|PkceAuthorize|Sensitive +/auth/pkce/complete|PkceComplete|Sensitive +/auth/pkce/try-complete|PkceTryComplete|Sensitive +/auth/refresh|Refresh|Frequent +/auth/try-login|TryLogin|Sensitive +/auth/uauthhub/entry|HubEntry|Sensitive +/auth/users/create|UserCreateAnonymous|Sensitive +/auth/validate|Validate|Frequent +/auth/antiforgery/token|AntiforgeryToken|Frequent +"""; + var actual = routes.Cast().Select(endpoint => + { + var metadata = Assert.Single(endpoint.Metadata, m => m.GetType().Name == "UAuthRateLimitMetadata"); + var type = metadata.GetType(); + var expectedMethods = endpoint.RoutePattern.RawText == "/auth/antiforgery/token" ? new[] { "GET" } : new[] { "POST" }; + + Assert.Equal(expectedMethods, endpoint.Metadata.GetMetadata()!.HttpMethods); + return $"{endpoint.RoutePattern.RawText}|{type.GetProperty("Endpoint")!.GetValue(metadata)}|{type.GetProperty("Group")!.GetValue(metadata)}"; + }).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.Equal(expected.Split('\n').Select(x => x.Trim()).OrderBy(x => x, StringComparer.Ordinal), actual); + } + + [Fact] + public async Task TrustedForwardingSeparatesClients_UnknownProxiesCannotRotateHeaders() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, trustedProxy: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", forwarded: "203.0.113.1")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", forwarded: "203.0.113.2")).StatusCode); + await host.Post("/auth/login", ip: "192.0.2.2", forwarded: "203.0.113.3"); + Assert.Equal(HttpStatusCode.TooManyRequests, + (await host.Post("/auth/login", ip: "192.0.2.2", forwarded: "203.0.113.4")).StatusCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NamedApplicationPolicyUsesEffectiveMiddlewareRejection(bool explicitOptions) + { + var options = new RateLimiterOptions(); + options.AddFixedWindowLimiter("application-login", r => { r.PermitLimit = 1; r.Window = TimeSpan.FromMinutes(1); }); + options.OnRejected = (rejected, ct) => { rejected.HttpContext.Response.StatusCode = 418; return ValueTask.CompletedTask; }; + await using var host = await Host.Create(o => o.RateLimiting.Login.PolicyName = "application-login", + native: explicitOptions ? null : o => + { + o.AddFixedWindowLimiter("application-login", r => { r.PermitLimit = 1; r.Window = TimeSpan.FromMinutes(1); }); + o.OnRejected = options.OnRejected; + }, middlewareOptions: explicitOptions ? options : null); + await host.Post("/auth/login"); + Assert.Equal(418, (int)(await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task BuiltInPolicyOwnsRejectionEvenWithExplicitMiddlewareOptions() + { + var options = new RateLimiterOptions { OnRejected = (_, _) => throw new InvalidOperationException("Global callback must not replace endpoint policy rejection.") }; + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, middlewareOptions: options); + await host.Post("/auth/login"); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task ChildPipelineDoesNotInheritInstallation_AndDeduplicatesItsOwnCalls() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1); + var endpoint = ((IEndpointRouteBuilder)host.App).DataSources.SelectMany(s => s.Endpoints) + .OfType().Single(e => e.RoutePattern.RawText == "/auth/login"); + var child = ((IApplicationBuilder)host.App).New(); + child.UseUltimateAuth(useRateLimiting: true); + child.UseUltimateAuth(useRateLimiting: true); + child.Run(context => { context.Response.StatusCode = 204; return Task.CompletedTask; }); + var pipeline = child.Build(); + async Task Send() + { + using var scope = host.App.Services.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1"); + context.Response.Body = new MemoryStream(); + context.SetEndpoint(endpoint); + await pipeline(context); + return context.Response.StatusCode; + } + Assert.Equal(204, await Send()); + Assert.Equal(429, await Send()); + } + + [Fact] + public void MiddlewareHelperSignaturesAndCallFormsRemainAvailable() + { + var type = typeof(UltimateAuthApplicationBuilderExtensions); + + Assert.NotNull(type.GetMethod( + "UseUltimateAuth", + new[] { typeof(IApplicationBuilder) })); + + Assert.NotNull(type.GetMethod( + "UseUltimateAuth", + new[] { typeof(IApplicationBuilder), typeof(bool) })); + + var method = type.GetMethod( + "UseUltimateAuthWithAspNetCore", + new[] + { + typeof(IApplicationBuilder), + typeof(bool?), + typeof(bool) + }); + + Assert.NotNull(method); + + var parameters = method.GetParameters(); + + Assert.True(parameters[1].IsOptional); + Assert.Null(parameters[1].DefaultValue); + + Assert.True(parameters[2].IsOptional); + Assert.Equal(true, parameters[2].DefaultValue); + + // Compile-time coverage for the supported public API. + static void Calls(IApplicationBuilder app) + { + app.UseUltimateAuth(); + app.UseUltimateAuth(true); + app.UseUltimateAuth(useRateLimiting: false); + + app.UseUltimateAuthWithAspNetCore(); + app.UseUltimateAuthWithAspNetCore(false); + app.UseUltimateAuthWithAspNetCore(null); + app.UseUltimateAuthWithAspNetCore(enableCors: true); + app.UseUltimateAuthWithAspNetCore(null, false); + app.UseUltimateAuthWithAspNetCore( + enableCors: null, + useRateLimiting: false); + } + + Action compiled = Calls; + Assert.NotNull(compiled); + } + + [Fact] + public async Task NativeDisableSkipsBothGlobalAndEndpointLimits() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, + native: o => o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetFixedWindowLimiter("global", _ => new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1) })), disableNative: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task GlobalAndBuiltInEndpointLimitsBothApply() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, native: o => + { + o.RejectionStatusCode = 418; + o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetFixedWindowLimiter("global", _ => new() { PermitLimit = 4, Window = TimeSpan.FromMinutes(1) })); + }); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/validate")).StatusCode); + // Native middleware may acquire global permits again after endpoint rejection. + Assert.Equal(418, (int)(await host.Post("/auth/refresh")).StatusCode); + } + + private sealed class Host(WebApplication app) : IAsyncDisposable + { + internal WebApplication App => app; + private readonly HttpClient _client = app.GetTestClient(); + internal async Task Post(string path, string ip = "192.0.2.1", string? forwarded = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.Add("Test-Connection-IP", ip); + if (forwarded is not null) request.Headers.Add("X-Forwarded-For", forwarded); + return await _client.SendAsync(request); + } + internal static async Task Create(Action? configure = null, + bool aspNet = true, bool? enabled = null, bool repeat = false, + Action? native = null, bool applicationPolicy = false, bool trustedProxy = false, bool disableNative = false, RateLimiterOptions? middlewareOptions = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" }); + builder.WebHost.UseTestServer(); + builder.Services.AddUltimateAuthServer(configure).AddUltimateAuthInMemory(); + if (native is not null) builder.Services.AddRateLimiter(native); + var app = builder.Build(); + app.Use((context, next) => + { + // Test transport only; production never reads this header. + context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["Test-Connection-IP"].ToString()); + return next(context); + }); + if (trustedProxy) + { + var forwarding = new Microsoft.AspNetCore.Builder.ForwardedHeadersOptions + { ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor }; + forwarding.KnownProxies.Add(IPAddress.Parse("192.0.2.1")); + app.UseForwardedHeaders(forwarding); + } + app.UseRouting(); + if (middlewareOptions is not null) app.UseRateLimiter(middlewareOptions); + if (aspNet) + { + if (middlewareOptions is not null) app.UseUltimateAuthWithAspNetCore(enableCors: null, useRateLimiting: false); + else if (enabled is null) app.UseUltimateAuthWithAspNetCore(); + else app.UseUltimateAuthWithAspNetCore(enableCors: null, useRateLimiting: enabled.Value); + if (repeat) app.UseUltimateAuthWithAspNetCore(); + } + else app.UseUltimateAuth(useRateLimiting: enabled ?? false); + var group = app.MapGroup(""); + if (applicationPolicy) group.RequireRateLimiting("Sensitive"); + if (disableNative) group.DisableRateLimiting(); + group.MapUltimateAuthEndpoints(); + group.MapUAuthHub(); + // Exercise the real endpoint inventory and middleware without invoking business handlers. + ((IEndpointConventionBuilder)group).Finally(b => b.RequestDelegate = context => { context.Response.StatusCode = 204; return Task.CompletedTask; }); + await app.StartAsync(); + return new Host(app); + } + public async ValueTask DisposeAsync() { _client.Dispose(); await app.DisposeAsync(); } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index 6ea68db0..47e9b25a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -40,7 +40,7 @@ public async Task Refresh_PureOpaque_BeforeTouchInterval_ShouldNotMutateChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync(user, "BlazorServer"); @@ -74,7 +74,7 @@ public async Task Refresh_PureOpaque_AfterTouchInterval_ShouldTouchChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync(user, "BlazorServer"); @@ -114,7 +114,7 @@ public async Task Refresh_PureOpaque_AfterTouch_ShouldNotTouchAgainBeforeNextInt { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync(user, "BlazorServer"); @@ -159,7 +159,7 @@ public async Task Refresh_PureOpaque_ShouldPreserveSessionAndChainIdentity() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync(user, "BlazorServer"); @@ -208,7 +208,7 @@ public async Task Refresh_PureOpaque_WithRevokedSession_ShouldReturnUnauthorized _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -230,10 +230,10 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( sessionId, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); var persisted = - await store.GetSessionAsync(sessionId); + await store.GetSessionAsync(sessionId, TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -251,7 +251,7 @@ public async Task Refresh_PureOpaque_WithRevokedChain_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -267,11 +267,11 @@ await store.ExecuteAsync( ct => store.RevokeChainCascadeAsync( current.ChainId, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); var persistedChain = await store.GetChainAsync( - current.ChainId); + current.ChainId, TestContext.Current.CancellationToken); persistedChain.Should().NotBeNull(); persistedChain!.IsRevoked.Should().BeTrue(); @@ -289,7 +289,7 @@ public async Task Refresh_PureOpaque_WithRevokedRoot_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -303,7 +303,7 @@ await LoginAsync( var rootBefore = await store.GetActiveRootByUserAsync( - user.UserKey); + user.UserKey, TestContext.Current.CancellationToken); rootBefore.Should().NotBeNull(); rootBefore!.IsRevoked.Should().BeFalse(); @@ -312,14 +312,14 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( user.UserKey, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); // // Active lookup must no longer return the revoked root. // var activeRootAfter = await store.GetActiveRootByUserAsync( - user.UserKey); + user.UserKey, TestContext.Current.CancellationToken); activeRootAfter.Should().BeNull(); @@ -328,7 +328,7 @@ await store.GetActiveRootByUserAsync( // var historicalRoot = await store.GetRootByIdAsync( - rootBefore.RootId); + rootBefore.RootId, TestContext.Current.CancellationToken); historicalRoot.Should().NotBeNull(); historicalRoot!.IsRevoked.Should().BeTrue(); @@ -338,7 +338,7 @@ await store.GetRootByIdAsync( // var chainAfter = await store.GetChainAsync( - current.ChainId); + current.ChainId, TestContext.Current.CancellationToken); chainAfter.Should().NotBeNull(); chainAfter!.IsRevoked.Should().BeTrue(); @@ -356,7 +356,7 @@ public async Task Refresh_PureOpaque_FromDifferentDevice_ShouldReturnUnauthorize _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var cookie = await LoginAsync( @@ -394,7 +394,7 @@ await LoginAsync( cookie); var response = - await otherClient.PostAsync( + await CsrfTestClient.PostAsync(otherClient, "/auth/refresh", null); @@ -427,7 +427,7 @@ public async Task Refresh_Hybrid_ShouldRotateCredentials() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var beforeCookie = await LoginAsync( @@ -474,7 +474,7 @@ public async Task Refresh_Hybrid_ShouldPreserveChainIdentity() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -507,7 +507,7 @@ public async Task Refresh_Hybrid_RotatedCredential_ShouldBeUsableForNextRefresh( _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -548,7 +548,7 @@ public async Task Refresh_Hybrid_ReusingPreviousCredential_ShouldReturnUnauthori _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var originalCookie = await LoginAsync( @@ -593,7 +593,7 @@ public async Task Refresh_Hybrid_WithoutRefreshToken_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var cookie = await LoginAsync( @@ -622,7 +622,7 @@ public async Task Refresh_Hybrid_WithRefreshTokenFromDifferentSession_ShouldRetu _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var clientA = CreateClient( @@ -660,7 +660,7 @@ await LoginAsync( $"{sessionB}; {refreshA}"); var response = - await clientB.PostAsync( + await CsrfTestClient.PostAsync(clientB, "/auth/refresh", null); @@ -674,7 +674,7 @@ public async Task Refresh_Hybrid_FromDifferentDevice_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var originalClient = CreateClient( @@ -697,7 +697,7 @@ await LoginAsync( cookie); var response = - await foreignClient.PostAsync( + await CsrfTestClient.PostAsync(foreignClient, "/auth/refresh", null); @@ -709,7 +709,7 @@ await foreignClient.PostAsync( // the legitimate credential set. // var legitimateRefresh = - await originalClient.PostAsync( + await CsrfTestClient.PostAsync(originalClient, "/auth/refresh", null); @@ -723,7 +723,7 @@ public async Task Refresh_Hybrid_WithRevokedSession_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -751,7 +751,7 @@ public async Task Refresh_Hybrid_WithRevokedChain_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -776,7 +776,7 @@ public async Task Refresh_Hybrid_WithRevokedRoot_ShouldReturnUnauthorized() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -798,7 +798,7 @@ public async Task Refresh_Hybrid_ConcurrentReuseOfSameRefreshToken_ShouldAllowEx _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-race-{Guid.NewGuid():N}"; @@ -831,8 +831,8 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync("/auth/refresh", null), - clientB.PostAsync("/auth/refresh", null)); + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); responses.Count(x => x.StatusCode == HttpStatusCode.NoContent) @@ -856,7 +856,7 @@ public async Task Refresh_Hybrid_ConcurrentDuplicate_ShouldLeaveWinningCredentia factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-concurrent-{Guid.NewGuid():N}"; @@ -889,8 +889,8 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync("/auth/refresh", null), - clientB.PostAsync("/auth/refresh", null)); + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); var winner = responses.Single(x => @@ -920,7 +920,7 @@ await Task.WhenAll( winningCredentials); var result = - await continuation.PostAsync( + await CsrfTestClient.PostAsync(continuation, "/auth/refresh", null); @@ -934,7 +934,7 @@ public async Task Refresh_Hybrid_AfterConcurrentRotation_OriginalRefreshToken_Sh _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-race-replay-{Guid.NewGuid():N}"; @@ -967,8 +967,8 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync("/auth/refresh", null), - clientB.PostAsync("/auth/refresh", null)); + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); responses.Count(x => x.StatusCode == HttpStatusCode.NoContent) @@ -990,7 +990,7 @@ await Task.WhenAll( originalCookies); var replay = - await replayClient.PostAsync( + await CsrfTestClient.PostAsync(replayClient, "/auth/refresh", null); @@ -1004,7 +1004,7 @@ public async Task Refresh_Hybrid_ConcurrentRotation_ShouldNotCreateAnotherChain( _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-race-chain-{Guid.NewGuid():N}"; @@ -1040,8 +1040,8 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync("/auth/refresh", null), - clientB.PostAsync("/auth/refresh", null)); + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); var success = responses.Single(x => @@ -1083,7 +1083,7 @@ public async Task Refresh_Hybrid_AfterSessionRevoked_ShouldReturnUnauthorized() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-revoke-session-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1114,14 +1114,14 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( before.ActiveSessionId!.Value, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); } client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); var response = - await client.PostAsync("/auth/refresh", null); + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -1132,7 +1132,7 @@ public async Task Refresh_Hybrid_AfterChainRevoked_ShouldReturnUnauthorized() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-revoke-chain-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1157,14 +1157,13 @@ await store.ExecuteAsync( ct => store.RevokeChainCascadeAsync( before.ChainId, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); } client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); - var response = - await client.PostAsync("/auth/refresh", null); + var response = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -1175,7 +1174,7 @@ public async Task Refresh_Hybrid_AfterRootRevoked_ShouldReturnUnauthorized() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-revoke-root-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1202,14 +1201,13 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( user.UserKey, _factory.Clock.UtcNow, - ct)); + ct), TestContext.Current.CancellationToken); } client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); - var response = - await client.PostAsync("/auth/refresh", null); + var response = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -1220,7 +1218,7 @@ public async Task Refresh_Hybrid_ReusingRotatedToken_ShouldReturnUnauthorized() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-replay-{Guid.NewGuid():N}"; using var loginClient = CreateClient(deviceId); @@ -1231,20 +1229,16 @@ await LoginAsync( user, "BlazorWasm"); - // // R0 -> R1 - // var first = - await loginClient.PostAsync( + await CsrfTestClient.PostAsync(loginClient, "/auth/refresh", null); first.StatusCode.Should() .Be(HttpStatusCode.NoContent); - // // Deliberately replay R0. - // using var replayClient = CreateClient(deviceId); @@ -1257,7 +1251,7 @@ await loginClient.PostAsync( originalCookies); var replay = - await replayClient.PostAsync( + await CsrfTestClient.PostAsync(replayClient, "/auth/refresh", null); @@ -1277,7 +1271,7 @@ public async Task Refresh_Hybrid_ReplayAfterConcurrencyWindow_ShouldInvalidateRe factory.Clock.Reset(); var user = - await factory.CreateLoginUserAsync(); + await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-replay-family-{Guid.NewGuid():N}"; @@ -1291,11 +1285,9 @@ await LoginAsync( user, "BlazorWasm"); - // // R0 -> R1 - // var rotation = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1307,15 +1299,11 @@ await client.PostAsync( originalCookies, rotation); - // // Move OUTSIDE the concurrency tolerance window. - // factory.Clock.Advance( TimeSpan.FromSeconds(3)); - // // Replay R0. - // using var attacker = CreateClient(factory, deviceId); @@ -1328,7 +1316,7 @@ await client.PostAsync( originalCookies); var replay = - await attacker.PostAsync( + await CsrfTestClient.PostAsync(attacker, "/auth/refresh", null); @@ -1351,7 +1339,7 @@ await attacker.PostAsync( replacementCookies); var legitimateRefresh = - await legitimateClient.PostAsync( + await CsrfTestClient.PostAsync(legitimateClient, "/auth/refresh", null); @@ -1369,7 +1357,7 @@ public async Task Refresh_Hybrid_DuplicateWithinConcurrencyWindow_ShouldNotInval factory.Clock.Reset(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"refresh-duplicate-window-{Guid.NewGuid():N}"; @@ -1378,7 +1366,7 @@ public async Task Refresh_Hybrid_DuplicateWithinConcurrencyWindow_ShouldNotInval var originalCookies = await LoginAsync(client, user, "BlazorWasm"); // R0 -> R1 - var rotation = await client.PostAsync("/auth/refresh", null); + var rotation = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); rotation.StatusCode.Should().Be(HttpStatusCode.NoContent); @@ -1392,7 +1380,7 @@ public async Task Refresh_Hybrid_DuplicateWithinConcurrencyWindow_ShouldNotInval duplicate.DefaultRequestHeaders.Add("Cookie", originalCookies); - var duplicateResponse = await duplicate.PostAsync("/auth/refresh", null); + var duplicateResponse = await CsrfTestClient.PostAsync(duplicate, "/auth/refresh", null); duplicateResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -1404,7 +1392,7 @@ public async Task Refresh_Hybrid_DuplicateWithinConcurrencyWindow_ShouldNotInval legitimate.DefaultRequestHeaders.Add("Cookie", replacementCookies); - var continuation = await legitimate.PostAsync("/auth/refresh", null); + var continuation = await CsrfTestClient.PostAsync(legitimate, "/auth/refresh", null); continuation.StatusCode.Should().Be(HttpStatusCode.NoContent); } @@ -1415,7 +1403,7 @@ public async Task Refresh_Hybrid_ReplayOnOneChain_ShouldNotAffectOtherDeviceChai _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceA = $"refresh-compromised-{Guid.NewGuid():N}"; @@ -1450,21 +1438,17 @@ await LoginAsync( chainA.ChainId.Should() .NotBe(chainB.ChainId); - // // Rotate device A: // A:R0 -> A:R1 - // var rotationA = - await clientA.PostAsync( + await CsrfTestClient.PostAsync(clientA, "/auth/refresh", null); rotationA.StatusCode.Should() .Be(HttpStatusCode.NoContent); - // // Replay A:R0. - // using var attacker = CreateClient(deviceA); @@ -1477,17 +1461,15 @@ await clientA.PostAsync( originalCookiesA); var replay = - await attacker.PostAsync( + await CsrfTestClient.PostAsync(attacker, "/auth/refresh", null); replay.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - // // Device B belongs to another chain. // Compromise of A must not kill B. - // using var safeClient = CreateClient(deviceB); @@ -1500,7 +1482,7 @@ await attacker.PostAsync( originalCookiesB); var safeRefresh = - await safeClient.PostAsync( + await CsrfTestClient.PostAsync(safeClient, "/auth/refresh", null); @@ -1513,7 +1495,7 @@ public async Task Refresh_Hybrid_JustBeforeRefreshTokenExpiry_ShouldSucceed() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-expiry-before-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1529,7 +1511,7 @@ public async Task Refresh_Hybrid_JustBeforeRefreshTokenExpiry_ShouldSucceed() client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1542,7 +1524,7 @@ public async Task Refresh_Hybrid_AtRefreshTokenExpiry_ShouldReturnUnauthorized() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-expiry-exact-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1558,7 +1540,7 @@ public async Task Refresh_Hybrid_AtRefreshTokenExpiry_ShouldReturnUnauthorized() client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1571,7 +1553,7 @@ public async Task Refresh_Hybrid_AfterRefreshTokenExpiry_ShouldReturnUnauthorize { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-expiry-after-{Guid.NewGuid():N}"; using var client = CreateClient(deviceId); @@ -1587,7 +1569,7 @@ public async Task Refresh_Hybrid_AfterRefreshTokenExpiry_ShouldReturnUnauthorize client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookies); - var response = await client.PostAsync( + var response = await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1608,7 +1590,7 @@ public async Task Refresh_Hybrid_WhenSessionExpired_ShouldReturnUnauthorized() factory.Clock.Reset(); var user = - await factory.CreateLoginUserAsync(); + await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-session-expiry-{Guid.NewGuid():N}"; @@ -1632,7 +1614,7 @@ await LoginAsync( cookies); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1654,7 +1636,7 @@ public async Task Refresh_Hybrid_WhenChainIdleTimeoutExceeded_ShouldReturnUnauth factory.Clock.Reset(); var user = - await factory.CreateLoginUserAsync(); + await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-idle-expiry-{Guid.NewGuid():N}"; @@ -1677,7 +1659,7 @@ await LoginAsync( cookies); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1699,7 +1681,7 @@ public async Task Refresh_Hybrid_JustBeforeChainIdleTimeout_ShouldSucceed() factory.Clock.Reset(); var user = - await factory.CreateLoginUserAsync(); + await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-idle-before-{Guid.NewGuid():N}"; @@ -1723,7 +1705,7 @@ await LoginAsync( cookies); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1745,7 +1727,7 @@ public async Task Refresh_Hybrid_AtChainIdleTimeout_ShouldReturnUnauthorized() factory.Clock.Reset(); var user = - await factory.CreateLoginUserAsync(); + await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-idle-exact-{Guid.NewGuid():N}"; @@ -1768,7 +1750,7 @@ await LoginAsync( cookies); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1786,7 +1768,7 @@ public async Task Refresh_Hybrid_OnSuccess_ShouldRotateRefreshCookie() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-cookie-rotate-{Guid.NewGuid():N}"; @@ -1804,7 +1786,7 @@ await LoginAsync( GetCookie(originalCookies, "uar"); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1830,7 +1812,7 @@ public async Task Refresh_Hybrid_OnSuccess_ShouldNotRotateSessionCookie() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-cookie-session-{Guid.NewGuid():N}"; @@ -1848,17 +1830,15 @@ await LoginAsync( GetCookie(originalCookies, "uas"); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); response.StatusCode.Should() .Be(HttpStatusCode.NoContent); - // // Hybrid refresh rotates the refresh credential. // Session identity itself must remain stable. - // var rotatedCredentials = BuildHybridCredentialAfterRefresh( originalCookies, @@ -1877,7 +1857,7 @@ public async Task Refresh_Hybrid_OnUnauthorized_ShouldNotIssueReplacementRefresh _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-cookie-failure-{Guid.NewGuid():N}"; @@ -1891,9 +1871,7 @@ await LoginAsync( user, "BlazorWasm"); - // // Remove refresh credential while preserving session. - // var session = GetCookie(originalCookies, "uas"); @@ -1904,7 +1882,7 @@ await LoginAsync( session); var response = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); @@ -1931,7 +1909,7 @@ public async Task Refresh_Hybrid_OnReplayDetection_ShouldNotIssueReplacementRefr _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-cookie-replay-{Guid.NewGuid():N}"; @@ -1945,20 +1923,16 @@ await LoginAsync( user, "BlazorWasm"); - // // R0 -> R1 - // var rotation = - await client.PostAsync( + await CsrfTestClient.PostAsync(client, "/auth/refresh", null); rotation.StatusCode.Should() .Be(HttpStatusCode.NoContent); - // // Replay R0. - // using var replayClient = CreateClient(deviceId); @@ -1971,7 +1945,7 @@ await client.PostAsync( originalCookies); var replay = - await replayClient.PostAsync( + await CsrfTestClient.PostAsync(replayClient, "/auth/refresh", null); @@ -1998,7 +1972,7 @@ public async Task Refresh_Hybrid_ConcurrentLoser_ShouldNotReceiveReplacementRefr _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var deviceId = $"hybrid-cookie-race-{Guid.NewGuid():N}"; @@ -2036,10 +2010,10 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync( + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), - clientB.PostAsync( + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); @@ -2089,7 +2063,7 @@ public async Task Refresh_PureOpaque_OnSuccess_ShouldNotIssueRefreshCookie() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await LoginAsync( user, @@ -2121,7 +2095,7 @@ public async Task Refresh_PureOpaque_OnSuccess_ShouldPreserveSessionCookie() _factory.Clock.Reset(); var user = - await _factory.CreateLoginUserAsync(); + await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var originalCookies = await LoginAsync( @@ -2174,7 +2148,7 @@ public async Task Refresh_Hybrid_CredentialFromTenantA_ShouldNotBeUsableInTenant var userA = await factory.CreateLoginUserAsync( - tenant: tenantA); + tenant: tenantA, ct: TestContext.Current.CancellationToken); using var loginClient = CreateClient( @@ -2191,9 +2165,7 @@ await LoginAsync( userA, "BlazorWasm"); - // // Same credential is now presented under Tenant B. - // using var tenantBClient = CreateClient( factory, @@ -2212,7 +2184,7 @@ await LoginAsync( cookiesA); var response = - await tenantBClient.PostAsync( + await CsrfTestClient.PostAsync(tenantBClient, "/auth/refresh", null); @@ -2238,7 +2210,7 @@ public async Task Refresh_Hybrid_CrossTenantAttempt_ShouldNotInvalidateSourceTen var userA = await factory.CreateLoginUserAsync( - tenant: tenantA); + tenant: tenantA, ct: TestContext.Current.CancellationToken); var deviceId = $"tenant-isolation-{Guid.NewGuid():N}"; @@ -2258,9 +2230,7 @@ await LoginAsync( userA, "BlazorWasm"); - // // Attack / accidental cross-tenant presentation. - // using var tenantBClient = CreateClient( factory, @@ -2279,7 +2249,7 @@ await LoginAsync( cookiesA); var crossTenant = - await tenantBClient.PostAsync( + await CsrfTestClient.PostAsync(tenantBClient, "/auth/refresh", null); @@ -2307,7 +2277,7 @@ await tenantBClient.PostAsync( cookiesA); var legitimate = - await tenantAClient.PostAsync( + await CsrfTestClient.PostAsync(tenantAClient, "/auth/refresh", null); @@ -2336,11 +2306,11 @@ public async Task Refresh_Hybrid_DifferentTenants_ShouldRotateIndependently() var userA = await factory.CreateLoginUserAsync( - tenant: tenantA); + tenant: tenantA, ct: TestContext.Current.CancellationToken); var userB = await factory.CreateLoginUserAsync( - tenant: tenantB); + tenant: tenantB, ct: TestContext.Current.CancellationToken); using var clientA = CreateClient( @@ -2372,10 +2342,10 @@ await LoginAsync( var responses = await Task.WhenAll( - clientA.PostAsync( + CsrfTestClient.PostAsync(clientA, "/auth/refresh", null), - clientB.PostAsync( + CsrfTestClient.PostAsync(clientB, "/auth/refresh", null)); @@ -2409,11 +2379,11 @@ public async Task Refresh_Hybrid_SessionFromTenantAAndRefreshFromTenantB_ShouldB var userA = await factory.CreateLoginUserAsync( - tenant: tenantA); + tenant: tenantA, ct: TestContext.Current.CancellationToken); var userB = await factory.CreateLoginUserAsync( - tenant: tenantB); + tenant: tenantB, ct: TestContext.Current.CancellationToken); using var clientA = CreateClient(factory, deviceId); @@ -2461,7 +2431,7 @@ await LoginAsync( mixedCredential); var response = - await attacker.PostAsync( + await CsrfTestClient.PostAsync(attacker, "/auth/refresh", null); @@ -2474,15 +2444,16 @@ public async Task Refresh_Hybrid_Maui_HeaderCredentials_ShouldSupportConsecutive { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"maui-refresh-{Guid.NewGuid():N}"); SetClientProfile(client, "Maui"); - // 1. Login - using var login = await client.PostAsJsonAsync( + // Login with CSRF protection. + using var login = await CsrfTestClient.PostJsonAsync( + client, "/auth/login", new { @@ -2498,42 +2469,55 @@ public async Task Refresh_Hybrid_Maui_HeaderCredentials_ShouldSupportConsecutive sessionId.Should().NotBeNullOrWhiteSpace(); refreshToken.Should().NotBeNullOrWhiteSpace(); - // 2. First refresh using the credentials delivered by login + // First refresh using MAUI header credentials. using var firstRequest = new HttpRequestMessage( - HttpMethod.Post, "/auth/refresh"); + HttpMethod.Post, + "/auth/refresh"); firstRequest.Headers.TryAddWithoutValidation( - "X-UAuth-Session", sessionId); + "X-UAuth-Session", + sessionId); firstRequest.Headers.TryAddWithoutValidation( - "X-Refresh-Token", refreshToken); + "X-Refresh-Token", + refreshToken); - using var first = await client.SendAsync(firstRequest); + using var first = await CsrfTestClient.SendAsync( + client, + firstRequest); - first.StatusCode.Should().Be(HttpStatusCode.NoContent); + first.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"first refresh failed: {await first.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)}"); var rotatedRefreshToken = ReadCredential(first, "uar"); rotatedRefreshToken.Should().NotBeNullOrWhiteSpace(); rotatedRefreshToken.Should().NotBe(refreshToken); - // Session ID may be reissued depending on the refresh policy. var nextSessionId = TryReadCredential(first, "uas") ?? sessionId; - // 3. Second refresh using the newly delivered refresh token + // Second refresh using rotated credentials. using var secondRequest = new HttpRequestMessage( - HttpMethod.Post, "/auth/refresh"); + HttpMethod.Post, + "/auth/refresh"); secondRequest.Headers.TryAddWithoutValidation( - "X-UAuth-Session", nextSessionId); + "X-UAuth-Session", + nextSessionId); secondRequest.Headers.TryAddWithoutValidation( - "X-Refresh-Token", rotatedRefreshToken); + "X-Refresh-Token", + rotatedRefreshToken); - using var second = await client.SendAsync(secondRequest); + using var second = await CsrfTestClient.SendAsync( + client, + secondRequest); - second.StatusCode.Should().Be(HttpStatusCode.NoContent); + second.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"second refresh failed: {await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)}"); } @@ -2542,7 +2526,7 @@ private async Task LoginAsync(IntegrationTestUser user, string profile = { SetClientProfile(profile); - var response = await _client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync(_client, "/auth/login", new { @@ -2578,7 +2562,7 @@ private static async Task LoginAsync(HttpClient client, IntegrationTestU profile); var response = - await client.PostAsJsonAsync( + await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -2599,7 +2583,7 @@ await client.PostAsJsonAsync( private Task RefreshAsync() { - return _client.PostAsync("/auth/refresh", null); + return CsrfTestClient.PostAsync(_client, "/auth/refresh", null); } private ISessionStore GetSessionStore() @@ -2644,7 +2628,7 @@ private static async Task GetCurrentChainAsync(HttpClient c private static async Task GetChainsAsync(HttpClient client) { - return await client.PostAsJsonAsync( + return await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { @@ -2751,14 +2735,9 @@ private async Task RevokeRootDirectlyAsync(UserKey userKey) private static string BuildHybridCredentialAfterRefresh(string originalCookies, HttpResponseMessage refreshResponse) { - var session = - GetCookie(originalCookies, "uas"); - - var refreshed = - BuildCookieHeader(refreshResponse); - - var refresh = - GetCookie(refreshed, "uar"); + var session = GetCookie(originalCookies, "uas"); + var refreshed = BuildCookieHeader(refreshResponse); + var refresh = GetCookie(refreshed, "uar"); return $"{session}; {refresh}"; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs index 9d4c94b3..3a34937f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthenticationTests.cs @@ -36,7 +36,7 @@ await ResourceApiTestHost.CreateAsync( validator.Object); var response = await host.Client.GetAsync( - "/__tests/resource/anonymous"); + "/__tests/resource/anonymous", TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -61,7 +61,7 @@ await ResourceApiTestHost.CreateAsync( validator.Object); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -97,7 +97,7 @@ await ResourceApiTestHost.CreateAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -126,7 +126,7 @@ await ResourceApiTestHost.CreateAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -150,7 +150,7 @@ await ResourceApiTestHost.CreateAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/admin"); + "/__tests/resource/admin", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Forbidden); @@ -174,7 +174,7 @@ await ResourceApiTestHost.CreateAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/admin"); + "/__tests/resource/admin", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -200,14 +200,14 @@ await ResourceApiTestHost.CreateAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/identity"); + "/__tests/resource/identity", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); var body = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); body.Should().NotBeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs index afc84bd6..013999c3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs @@ -50,7 +50,7 @@ public async Task PolicyEndpoint_WithoutCredential_ShouldReturnUnauthorized() await ResourceApiTestHost.CreateAsync(validator.Object); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -75,7 +75,7 @@ public async Task PolicyEndpoint_WithInvalidSession_ShouldReturnUnauthorized() AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -99,7 +99,7 @@ await CreateHostAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -129,7 +129,7 @@ await CreateHostAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Forbidden); @@ -166,7 +166,7 @@ await CreateHostAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -224,7 +224,7 @@ await CreateHostAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -267,7 +267,7 @@ await CreateHostAsync( AddSession(host.Client); var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -316,7 +316,7 @@ await CreateHostAsync( HttpMethod.Put, "/__tests/resource/products/42"); - var response = await host.Client.SendAsync(request); + var response = await host.Client.SendAsync(request, TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -356,7 +356,7 @@ public async Task PolicyEndpoint_WhenReauthenticationRequired_ShouldReturn401Pro AddSession(host.Client); using var response = await host.Client.GetAsync( - "/__tests/resource/products/read"); + "/__tests/resource/products/read", TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -364,7 +364,7 @@ public async Task PolicyEndpoint_WhenReauthenticationRequired_ShouldReturn401Pro .MediaType.Should().Be("application/problem+json"); var problem = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); problem.Should().NotBeNull(); problem!.Status.Should().Be(401); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs index bd199cd8..0610401b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiTenantIsolationTests.cs @@ -61,7 +61,7 @@ public async Task RequestTenant_ShouldBePassedToSessionValidation() AddTenant(host.Client, "tenant-a"); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -91,7 +91,7 @@ public async Task TenantA_WithTenantASession_ShouldAuthenticate() AddTenant(host.Client, "tenant-a"); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -118,7 +118,7 @@ public async Task TenantB_WithTenantASession_ShouldNotAuthenticate() AddTenant(host.Client, "tenant-b"); var response = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -156,7 +156,7 @@ public async Task SameSessionId_ShouldRemainTenantScoped() AddTenant(host.Client, "tenant-a"); var tenantAResponse = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); tenantAResponse.StatusCode.Should() .Be(HttpStatusCode.OK); @@ -165,7 +165,7 @@ public async Task SameSessionId_ShouldRemainTenantScoped() AddTenant(host.Client, "tenant-b"); var tenantBResponse = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); tenantBResponse.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -199,14 +199,14 @@ public async Task ResolvedTenant_ShouldBePreservedInValidationContext() AddTenant(host.Client, "tenant-a"); var tenantAResponse = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); host.Client.DefaultRequestHeaders.Remove(TenantHeader); AddTenant(host.Client, "tenant-b"); var tenantBResponse = await host.Client.GetAsync( - "/__tests/resource/authenticated"); + "/__tests/resource/authenticated", TestContext.Current.CancellationToken); tenantAResponse.StatusCode.Should() .Be(HttpStatusCode.OK); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs index 2dbcd496..b0200497 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs @@ -24,10 +24,10 @@ public sealed class SdkEndpointContractTests public async Task ChangeMyStatus_ShouldReturnTypedJson_NotEmpty200() { using var factory = new AuthServerFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, user); - using var response = await client.PostAsJsonAsync("/auth/me/status", + using var response = await CsrfTestClient.PostJsonAsync(client, "/auth/me/status", new ChangeUserStatusSelfRequest { NewStatus = SelfAssignableUserStatus.SelfSuspended }); var result = await AssertTypedSuccessAsync(response); result.IsSuccess.Should().BeTrue(); @@ -39,12 +39,13 @@ public async Task ChangeMyStatus_ShouldReturnTypedJson_NotEmpty200() public async Task ChangeUserStatus_ShouldReturnTypedJson_NotEmpty200() { using var factory = new AuthServerFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, $"/auth/admin/users/{target.UserKey.Value}/status", new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }); var result = await AssertTypedSuccessAsync(response); @@ -57,16 +58,16 @@ public async Task ChangeUserStatus_ShouldReturnTypedJson_NotEmpty200() public async Task ChangeUserStatus_AlreadyInRequestedStatus_ShouldReturnTypedJson() { using var factory = new AuthServerFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); var url = $"/auth/admin/users/{target.UserKey.Value}/status"; var request = new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }; - using var first = await client.PostAsJsonAsync(url, request); + using var first = await CsrfTestClient.PostJsonAsync(client, url, request); (await AssertTypedSuccessAsync(first)).IsSuccess.Should().BeTrue(); - using var second = await client.PostAsJsonAsync(url, request); + using var second = await CsrfTestClient.PostJsonAsync(client, url, request); var result = await AssertTypedSuccessAsync(second); result.IsSuccess.Should().BeTrue(); result.PreviousStatus.Should().Be(UserStatus.Suspended); @@ -77,12 +78,13 @@ public async Task ChangeUserStatus_AlreadyInRequestedStatus_ShouldReturnTypedJso public async Task DeleteUser_ShouldReturnTypedJson_WithRequestedMode() { using var factory = new AuthServerFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.DeleteAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.DeleteAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, $"/auth/admin/users/{target.UserKey.Value}/delete", new DeleteUserRequest { Mode = DeleteMode.Soft }); var result = await AssertTypedSuccessAsync(response); @@ -95,16 +97,17 @@ public async Task AdminBeginReset_ShouldReturnTypedJson_WithoutProof() { var notifier = new CapturingResetNotifier(); using var factory = CreateResetFactory(notifier); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(target)); var result = await AssertTypedSuccessAsync(response); result.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); - var body = await response.Content.ReadAsStringAsync(); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var notification = notifier.Notifications.Should().ContainSingle(x => x.UserKey == target.UserKey).Subject; body.Should().NotContain(notification.Token, "reset proof must only be sent through the notifier"); } @@ -114,18 +117,18 @@ public async Task AdminCompleteReset_ShouldReturnTypedJson_AndChangeCredential() { var notifier = new CapturingResetNotifier(); using var factory = CreateResetFactory(notifier); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await factory.GrantPermissionsAsync(admin.UserKey, - [UAuthActions.Credentials.BeginResetAdmin, UAuthActions.Credentials.CompleteResetAdmin]); + [UAuthActions.Credentials.BeginResetAdmin, UAuthActions.Credentials.CompleteResetAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); var url = $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; - using var begin = await client.PostAsJsonAsync($"{url}/begin", BeginResetRequest(target)); + using var begin = await CsrfTestClient.PostJsonAsync(client, $"{url}/begin", BeginResetRequest(target)); await AssertTypedSuccessAsync(begin); var token = notifier.Notifications.Single(x => x.UserKey == target.UserKey).Token; var newSecret = $"Changed-{Guid.NewGuid():N}!"; - using var complete = await client.PostAsJsonAsync($"{url}/complete", + using var complete = await CsrfTestClient.PostJsonAsync(client, $"{url}/complete", new CompleteResetCredentialRequest { Identifier = target.Identifier, @@ -142,14 +145,14 @@ public async Task AdminBeginReset_TargetMismatch_ShouldReturn400_AndNotNotify() { var notifier = new CapturingResetNotifier(); using var factory = CreateResetFactory(notifier); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - var other = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var other = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); using var response = await client.PostAsJsonAsync( - $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(other)); + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(other), cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); notifier.Notifications.Should().BeEmpty(); } @@ -158,10 +161,10 @@ public async Task AdminBeginReset_TargetMismatch_ShouldReturn400_AndNotNotify() public async Task RevokeMy_UnknownCredential_ShouldNotReturnSuccessful204() { using var factory = new AuthServerFactory(); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, user); - using var response = await client.PostAsJsonAsync("/auth/me/credentials/revoke", + using var response = await CsrfTestClient.PostJsonAsync(client, "/auth/me/credentials/revoke", new RevokeCredentialRequest { Id = Guid.NewGuid() }); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -170,12 +173,12 @@ public async Task RevokeMy_UnknownCredential_ShouldNotReturnSuccessful204() public async Task RevokeAdmin_UnknownCredential_ShouldNotReturnSuccessful204() { using var factory = new AuthServerFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.RevokeAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.RevokeAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"/auth/admin/users/{target.UserKey.Value}/credentials/revoke", new RevokeCredentialRequest { Id = Guid.NewGuid() }); response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -185,12 +188,12 @@ public async Task RevokeAdmin_UnknownCredential_ShouldNotReturnSuccessful204() public async Task DeleteCredentialAdmin_UnknownCredential_ShouldNotReturnSuccessful204() { using var factory = new AuthServerFactory(); - var admin = await factory.CreateLoginUserAsync(); - var target = await factory.CreateLoginUserAsync(); - await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.DeleteAdmin]); + var admin = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.DeleteAdmin], TestContext.Current.CancellationToken); using var client = CreateClient(factory); await AuthenticateAsync(client, admin); - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync(client, $"/auth/admin/users/{target.UserKey.Value}/credentials/delete", new DeleteCredentialRequest { Id = Guid.NewGuid(), Mode = DeleteMode.Soft }); response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -247,7 +250,7 @@ private static HttpClient CreateClient(AuthServerFactory factory) private static async Task AuthenticateAsync(HttpClient client, IntegrationTestUser user) { - using var response = await client.PostAsJsonAsync("/auth/login", new + using var response = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier = user.Identifier, secret = user.Secret diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs new file mode 100644 index 00000000..0d17113c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs @@ -0,0 +1,94 @@ +ο»Ώ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class AntiforgeryCookiePolicyTests +{ + [Theory] + [InlineData(UAuthHubDeploymentMode.Embedded, SameSiteMode.Strict)] + [InlineData(UAuthHubDeploymentMode.Integrated, SameSiteMode.Strict)] + [InlineData(UAuthHubDeploymentMode.External, SameSiteMode.None)] + public async Task AntiforgeryCookie_UsesDeploymentPolicy( + UAuthHubDeploymentMode deployment, + SameSiteMode expected) + { + await using var factory = AuthServerFactory.Create( + options => options.HubDeploymentMode = deployment); + + var antiforgery = factory.Services + .GetRequiredService>() + .Value; + + antiforgery.Cookie.SameSite.Should().Be(expected); + + if (expected == SameSiteMode.None) + { + antiforgery.Cookie.SecurePolicy + .Should().Be(CookieSecurePolicy.Always); + } + } + + [Fact] + public async Task ExternalHub_AntiforgeryTokenCookie_IsSecureAndCrossSite() + { + await using var factory = AuthServerFactory.Create( + options => options.HubDeploymentMode = + UAuthHubDeploymentMode.External); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false + }); + + using var response = await client.GetAsync( + "/auth/antiforgery/token", + TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + + response.Headers.TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + var cookie = cookies!.Single(x => + x.StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + + cookie.Contains( + "samesite=none", + StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + + cookie.Split(';', StringSplitOptions.TrimEntries) + .Any(x => x.Equals( + "secure", + StringComparison.OrdinalIgnoreCase)) + .Should().BeTrue(); + } + + [Fact] + public async Task ExplicitAntiforgeryCookiePolicy_OverridesDeploymentDefault() + { + await using var factory = AuthServerFactory.Create(options => + { + options.HubDeploymentMode = UAuthHubDeploymentMode.External; + options.Antiforgery.CookieSameSite = SameSiteMode.Lax; + }); + + var antiforgery = factory.Services + .GetRequiredService>() + .Value; + + antiforgery.Cookie.SameSite.Should().Be(SameSiteMode.Lax); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs new file mode 100644 index 00000000..e11c38a3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs @@ -0,0 +1,344 @@ +ο»Ώusing System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class CsrfAttackTests +{ + [Fact] + public async Task Login_WithoutCsrfToken_MustNotCreateSession() + { + await using var factory = new AuthServerFactory(); + var attacker = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + + using var victimBrowser = CreateBrowser(factory); + + using var response = await victimBrowser.PostAsJsonAsync( + "/auth/login", + new + { + identifier = attacker.Identifier, + secret = attacker.Secret + }, cancellationToken: TestContext.Current.CancellationToken); + + await AssertCsrfRejected(response); + AssertNoSessionCookie(response); + } + + [Fact] + public async Task Login_WithForgedCsrfToken_MustNotCreateSession() + { + await using var factory = new AuthServerFactory(); + var ct = TestContext.Current.CancellationToken; + + var user = await factory.CreateLoginUserAsync(ct: ct); + + using var victimBrowser = CreateBrowser(factory); + + // Obtain a real antiforgery cookie and request token. + var tokens = await GetTokens(victimBrowser); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = user.Identifier, + secret = user.Secret + }) + }; + + // Keep the legitimate cookie, but forge the request token. + request.Headers.TryAddWithoutValidation(tokens.HeaderName, "attacker-forged-token"); + request.Headers.TryAddWithoutValidation("Cookie", tokens.Cookie); + + using var response = await victimBrowser.SendAsync(request, ct); + + await AssertCsrfRejected(response); + AssertNoSessionCookie(response); + } + + [Fact] + public async Task Login_WithTokenFromAnotherBrowser_MustBeRejected() + { + await using var factory = new AuthServerFactory(); + var attacker = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + + using var attackerBrowser = CreateBrowser(factory); + using var victimBrowser = CreateBrowser(factory); + + var attackerTokens = await GetTokens(attackerBrowser); + var victimTokens = await GetTokens(victimBrowser); + + // Victim browser has its own antiforgery cookie, + // but attacker supplies a token from a different browser. + using var request = new HttpRequestMessage( + HttpMethod.Post, "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = attacker.Identifier, + secret = attacker.Secret + }) + }; + + request.Headers.TryAddWithoutValidation( + attackerTokens.HeaderName, + attackerTokens.RequestToken); + + request.Headers.TryAddWithoutValidation( + "Cookie", + victimTokens.Cookie); + + using var response = await victimBrowser.SendAsync(request, TestContext.Current.CancellationToken); + + await AssertCsrfRejected(response); + AssertNoSessionCookie(response); + } + + [Fact] + public async Task Login_WithValidCsrfToken_ShouldSucceed() + { + await using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + + using var browser = CreateBrowser(factory); + var tokens = await GetTokens(browser); + + using var request = new HttpRequestMessage( + HttpMethod.Post, "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = user.Identifier, + secret = user.Secret + }) + }; + + request.Headers.TryAddWithoutValidation( + tokens.HeaderName, + tokens.RequestToken); + + request.Headers.TryAddWithoutValidation( + "Cookie", + tokens.Cookie); + + using var response = await browser.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers.TryGetValues( + "Set-Cookie", out var cookies).Should().BeTrue(); + + cookies!.Should().Contain(x => + x.StartsWith("uas=", StringComparison.OrdinalIgnoreCase)); + } + + + [Fact] + public async Task RevokeOthers_CsrfAttack_MustNotRevokeSessions() + { + await using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + + using var currentBrowser = CreateBrowser(factory); + using var otherBrowser = CreateBrowser(factory); + + // Create two independent authenticated sessions. + var currentCookie = await LoginAndGetSessionCookie( + currentBrowser, user); + + var otherCookie = await LoginAndGetSessionCookie( + otherBrowser, user); + + SetSessionCookie(currentBrowser, currentCookie); + SetSessionCookie(otherBrowser, otherCookie); + + // Verify both sessions are initially usable. + await AssertAuthenticated(currentBrowser); + await AssertAuthenticated(otherBrowser); + + // ATTACK 1: Authenticated request without CSRF token. + using (var missingToken = await currentBrowser.PostAsync( + "/auth/me/sessions/revoke-others", null, TestContext.Current.CancellationToken)) + { + await AssertCsrfRejected(missingToken); + } + + // The attack must not revoke the other session. + await AssertAuthenticated(otherBrowser); + + // ATTACK 2: Valid antiforgery cookie with a forged request token. + var forgedTokens = await GetTokens(currentBrowser); + + using (var forgedRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/me/sessions/revoke-others")) + { + forgedRequest.Headers.TryAddWithoutValidation(forgedTokens.HeaderName, "attacker-forged-token"); + + // Preserve the authenticated session cookie and + // include the legitimate antiforgery cookie. + forgedRequest.Headers.TryAddWithoutValidation("Cookie", $"{currentCookie}; {forgedTokens.Cookie}"); + + using var forged = await currentBrowser.SendAsync(forgedRequest, TestContext.Current.CancellationToken); + await AssertCsrfRejected(forged); + } + + // A rejected CSRF request must not revoke the other session. + await AssertAuthenticated(otherBrowser); + + // CONTROL: Valid CSRF token must allow the operation. + using (var valid = await CsrfTestClient.PostAsync(currentBrowser, "/auth/me/sessions/revoke-others")) + { + valid.IsSuccessStatusCode.Should().BeTrue( + $"valid revoke failed: " + + await valid.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + // The current session must survive revoke-others. + await AssertAuthenticated(currentBrowser); + + // The other session must now be revoked. + using var revokedCheck = await CsrfTestClient.PostJsonAsync(otherBrowser, "/auth/me/identifiers/get", new { }); + + revokedCheck.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + + private static async Task LoginAndGetSessionCookie( + HttpClient browser, + IntegrationTestUser user) + { + var tokens = await GetTokens(browser); + + using var request = new HttpRequestMessage( + HttpMethod.Post, "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = user.Identifier, + secret = user.Secret + }) + }; + + request.Headers.TryAddWithoutValidation( + tokens.HeaderName, + tokens.RequestToken); + + request.Headers.TryAddWithoutValidation( + "Cookie", + tokens.Cookie); + + using var response = await browser.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers.TryGetValues( + "Set-Cookie", out var cookies).Should().BeTrue(); + + return cookies! + .Select(x => x.Split(';', 2)[0]) + .Single(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + } + + private static void SetSessionCookie( + HttpClient browser, + string sessionCookie) + { + browser.DefaultRequestHeaders.Remove("Cookie"); + browser.DefaultRequestHeaders.TryAddWithoutValidation( + "Cookie", + sessionCookie); + } + + private static async Task AssertAuthenticated( + HttpClient browser) + { + using var response = await CsrfTestClient.PostJsonAsync( + browser, + "/auth/me/identifiers/get", + new { }); + + response.StatusCode.Should().Be( + HttpStatusCode.OK, + $"session should remain authenticated: " + + await response.Content.ReadAsStringAsync()); + } + + + + private static HttpClient CreateBrowser(AuthServerFactory factory) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", $"csrf-attack-{Guid.NewGuid():N}"); + + return client; + } + + private static async Task GetTokens(HttpClient client) + { + using var response = await client.GetAsync( + "/auth/antiforgery/token"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var json = JsonDocument.Parse( + await response.Content.ReadAsStringAsync()); + + var token = json.RootElement + .GetProperty("requestToken").GetString()!; + + var headerName = json.RootElement + .GetProperty("headerName").GetString()!; + + response.Headers.TryGetValues( + "Set-Cookie", out var cookies).Should().BeTrue(); + + var cookie = cookies! + .Select(x => x.Split(';', 2)[0]) + .Single(x => x.StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + + return new CsrfTokens(token, headerName, cookie); + } + + private static async Task AssertCsrfRejected( + HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("invalid_antiforgery_token"); + } + + private static void AssertNoSessionCookie( + HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var cookies)) + return; + + cookies.Should().NotContain(x => + x.StartsWith("uas=", StringComparison.OrdinalIgnoreCase)); + } + + private sealed record CsrfTokens( + string RequestToken, + string HeaderName, + string Cookie); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthAntiforgeryEndpointTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthAntiforgeryEndpointTests.cs new file mode 100644 index 00000000..c9c4cc07 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthAntiforgeryEndpointTests.cs @@ -0,0 +1,204 @@ +ο»Ώusing System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class UAuthAntiforgeryEndpointTests +{ + private sealed record TokenResponse( + string RequestToken, + string HeaderName, + string FormFieldName); + + [Fact] + public async Task TokenEndpoint_ReturnsValidTokenAndNoStore() + { + await using var factory = new AuthServerFactory(); + + using var client = factory.CreateClient(); + + using var response = await client.GetAsync( + "/auth/antiforgery/token", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Contains( + "no-store", + response.Headers.CacheControl?.ToString() ?? ""); + + var token = await response.Content + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(token); + Assert.False(string.IsNullOrWhiteSpace(token.RequestToken)); + Assert.False(string.IsNullOrWhiteSpace(token.HeaderName)); + Assert.False(string.IsNullOrWhiteSpace(token.FormFieldName)); + + Assert.Contains( + response.Headers.TryGetValues("Set-Cookie", out var cookies) + ? cookies + : Array.Empty(), + cookie => cookie.Contains("Antiforgery")); + } + + [Fact] + public async Task Login_WithoutToken_IsRejected() + { + await using var factory = new AuthServerFactory(); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true + }); + + using var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = "csrf-test-user", + secret = "irrelevant" + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Contains( + "invalid_antiforgery_token", + body); + } + + [Fact] + public async Task Login_WithInvalidToken_IsRejected() + { + await using var factory = new AuthServerFactory(); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true + }); + + await client.GetAsync("/auth/antiforgery/token", TestContext.Current.CancellationToken); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = "csrf-test-user", + secret = "irrelevant" + }) + }; + + request.Headers.Add( + "X-UAuth-CSRF", + "invalid-token"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Login_WithValidToken_PassesCsrfValidation() + { + await using var factory = new AuthServerFactory(); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true + }); + + var token = await client.GetFromJsonAsync( + "/auth/antiforgery/token", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(token); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = "nonexistent-user", + secret = "invalid-password" + }) + }; + + request.Headers.Add( + token.HeaderName, + token.RequestToken); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.DoesNotContain( + "invalid_antiforgery_token", + body); + } + + [Fact] + public async Task TokenFromAnotherClient_IsRejected() + { + await using var factory = new AuthServerFactory(); + + using var firstClient = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + HandleCookies = true, + AllowAutoRedirect = false + }); + + using var secondClient = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + HandleCookies = true, + AllowAutoRedirect = false + }); + + var firstToken = + await firstClient.GetFromJsonAsync( + "/auth/antiforgery/token", cancellationToken: TestContext.Current.CancellationToken); + + // Ensure the second client has its own antiforgery cookie. + await secondClient.GetAsync("/auth/antiforgery/token", TestContext.Current.CancellationToken); + + Assert.NotNull(firstToken); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = "csrf-test-user", + secret = "irrelevant" + }) + }; + + request.Headers.Add( + firstToken.HeaderName, + firstToken.RequestToken); + + using var response = await secondClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Contains( + "invalid_antiforgery_token", + body); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthCsrfEndpointCoverageTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthCsrfEndpointCoverageTests.cs new file mode 100644 index 00000000..9632cb6b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/UAuthCsrfEndpointCoverageTests.cs @@ -0,0 +1,184 @@ +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Server.Security; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class CsrfEndpointCoverageTests +{ + [Fact] + public void AllAuthenticationAndMutationEndpoints_MustDeclareCsrfProtection() + { + using var factory = new AuthServerFactory(); + + // Ensure the application and endpoint routing are initialized. + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + var endpoints = factory.Services + .GetRequiredService() + .Endpoints + .OfType() + .Where(e => e.RoutePattern.RawText?.TrimStart('/').StartsWith("auth/", StringComparison.OrdinalIgnoreCase) == true) + .ToArray(); + + endpoints.Should().NotBeEmpty(); + + var expected = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Authentication + ["auth/login"] = UAuthCsrfOperation.Login, + ["auth/try-login"] = UAuthCsrfOperation.Login, + ["auth/pkce/complete"] = UAuthCsrfOperation.PkceCompletion, + ["auth/pkce/try-complete"] = UAuthCsrfOperation.PkceCompletion, + + // Session and logout + ["auth/logout"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/refresh"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/logout-device"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/logout-others"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/logout-all"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/sessions/revoke-others"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/sessions/revoke-all"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/sessions/chains/{chainId}/revoke"] = UAuthCsrfOperation.AuthenticatedMutation, + + // User lifecycle + ["auth/me/status"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/create"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/status"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Profiles + ["auth/me/profile/create"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/profile/update"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/profile/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/profile/create"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/profile/update"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/profile/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Self identifiers + ["auth/me/identifiers/add"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/identifiers/update"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/identifiers/set-primary"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/identifiers/unset-primary"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/identifiers/verify"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/identifiers/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Admin identifiers + ["auth/admin/users/{userKey}/identifiers/add"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/identifiers/update"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/identifiers/set-primary"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/identifiers/unset-primary"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/identifiers/verify"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/identifiers/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Self credentials + ["auth/me/credentials/add"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/credentials/change"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/me/credentials/revoke"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Admin credentials + ["auth/admin/users/{userKey}/credentials/add"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/credentials/change"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/credentials/revoke"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/credentials/reset/begin"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/credentials/reset/complete"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/credentials/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Authorization + ["auth/admin/authorization/users/{userKey}/roles/assign"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/authorization/users/{userKey}/roles/remove"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/authorization/roles/create"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/authorization/roles/{roleId}/rename"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/authorization/roles/{roleId}/permissions"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/authorization/roles/{roleId}/delete"] = UAuthCsrfOperation.AuthenticatedMutation, + + // Admin session operations + ["auth/admin/users/{userKey}/logout-device"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/logout-others"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/logout-all"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/sessions/{sessionId}/revoke"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/sessions/chains/{chainId}/revoke"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/sessions/revoke-root"] = UAuthCsrfOperation.AuthenticatedMutation, + ["auth/admin/users/{userKey}/sessions/revoke-all"] = UAuthCsrfOperation.AuthenticatedMutation + }; + + var actual = endpoints + .Where(e => e.Metadata.GetMetadata()?.HttpMethods.Contains("POST") == true) + .ToDictionary( + e => e.RoutePattern.RawText!.TrimStart('/'), + e => e.Metadata.GetMetadata()?.Operation, + StringComparer.OrdinalIgnoreCase); + + // Every expected protected endpoint must exist and carry + // the exact expected CSRF operation. + foreach (var (route, operation) in expected) + { + actual.Should().ContainKey(route, $"protected endpoint {route} must be registered"); + actual[route].Should().Be(operation, $"endpoint {route} must declare {operation}"); + } + + // Explicitly unprotected POST endpoints must be reviewed. + // These are read-only or anonymous flows with separate security semantics. + var explicitlyExcluded = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "auth/validate", + "auth/password-policy", + "auth/pkce/authorize", + "auth/users/create", + "auth/me/credentials/reset/begin", + "auth/me/credentials/reset/complete", + + "auth/me/sessions/chains", + "auth/me/sessions/chains/{chainId}", + "auth/admin/users/{userKey}/sessions/chains", + "auth/admin/users/{userKey}/sessions/chains/{chainId}", + + "auth/admin/users/query", + "auth/admin/users/get-by-keys", + "auth/me/profile/get", + "auth/admin/users/{userKey}/profile/get", + "auth/me/identifiers/get", + "auth/admin/users/{userKey}/identifiers/get", + "auth/me/identifiers/check-availability", + + "auth/me/authorization/check", + "auth/me/authorization/roles/get", + "auth/me/authorization/permissions/get", + "auth/admin/authorization/users/{userKey}/roles/get", + "auth/admin/authorization/roles/query", + + // UAuthHub bootstrap endpoint: + // Creates a short-lived HubFlowArtifact only. + // Does not authenticate the user or issue session credentials. + // PKCE completion performs the actual authentication. + "auth/uauthhub/entry", + }; + + var unexpected = actual.Keys + .Except(expected.Keys, StringComparer.OrdinalIgnoreCase) + .Except(explicitlyExcluded, StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToArray(); + + unexpected.Should().BeEmpty( + "new POST endpoints require an explicit CSRF security classification"); + + // Exclusions must not silently acquire a different behavior. + foreach (var route in explicitlyExcluded) + { + if (actual.TryGetValue(route, out var metadata)) + { + metadata.Should().BeNull( + $"excluded endpoint {route} must be reviewed before changing its CSRF classification"); + } + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs index 14f710a9..c6452ad3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs @@ -23,15 +23,15 @@ public async Task ListChainsAdmin_ShouldReturnTargetUsersChains() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); - var unrelated = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelated = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.ListChainsAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-admin-list-{Guid.NewGuid():N}"); @@ -76,12 +76,12 @@ await LoginAsync( { PageNumber = 1, PageSize = 50 - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var page = await response.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -103,14 +103,14 @@ public async Task GetChainAdmin_ShouldReturnTargetUsersOwnedChain() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.GetChainAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-admin-detail-{Guid.NewGuid():N}"); @@ -143,7 +143,7 @@ await LoginAsync( targetChains.StatusCode.Should().Be(HttpStatusCode.OK); var page = await targetChains.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -152,12 +152,12 @@ await LoginAsync( var response = await adminClient.PostAsync( $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}", - null); + null, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); var detail = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); detail.Should().NotBeNull(); @@ -176,16 +176,16 @@ public async Task GetChainAdmin_ShouldRejectChainOwnedByDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); - var targetA = await _factory.CreateLoginUserAsync(); - var targetB = await _factory.CreateLoginUserAsync(); + var targetA = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetB = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.GetChainAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-admin-cross-user-{Guid.NewGuid():N}"); @@ -233,7 +233,7 @@ await LoginAsync( await GetChainsAsync(targetBClient); var targetBPage = await targetBChains.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetBPage.Should().NotBeNull(); @@ -245,7 +245,7 @@ await LoginAsync( // var response = await adminClient.PostAsync( $"/auth/admin/users/{targetA.UserKey.Value}/sessions/chains/{targetBChain.ChainId.Value}", - null); + null, TestContext.Current.CancellationToken); response.StatusCode.Should().BeOneOf( HttpStatusCode.BadRequest, @@ -274,8 +274,8 @@ public async Task SessionAdminQueryEndpoints_ShouldRequireAdminPermission() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"session-admin-no-permission-{Guid.NewGuid():N}"); @@ -307,7 +307,7 @@ await LoginAsync( await GetChainsAsync(targetClient); var targetPage = await targetChains.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetPage.Should().NotBeNull(); @@ -316,7 +316,7 @@ await LoginAsync( var list = await actorClient.PostAsJsonAsync( $"/auth/admin/users/{target.UserKey.Value}/sessions/chains", - new PageRequest()); + new PageRequest(), cancellationToken: TestContext.Current.CancellationToken); list.StatusCode.Should().BeOneOf( HttpStatusCode.Forbidden, @@ -324,7 +324,7 @@ await LoginAsync( var detail = await actorClient.PostAsync( $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}", - null); + null, TestContext.Current.CancellationToken); detail.StatusCode.Should().BeOneOf( HttpStatusCode.Forbidden, @@ -343,7 +343,7 @@ await LoginAsync( [Fact] public async Task SessionAdminQueryEndpoints_ShouldRejectUnauthenticatedRequests() { - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var anonymous = CreateClient( $"session-admin-anonymous-{Guid.NewGuid():N}"); @@ -352,14 +352,14 @@ public async Task SessionAdminQueryEndpoints_ShouldRejectUnauthenticatedRequests var list = await anonymous.PostAsJsonAsync( $"/auth/admin/users/{target.UserKey.Value}/sessions/chains", - new PageRequest()); + new PageRequest(), cancellationToken: TestContext.Current.CancellationToken); list.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); var detail = await anonymous.PostAsync( $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{randomChain.Value}", - null); + null, TestContext.Current.CancellationToken); detail.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); @@ -370,14 +370,14 @@ public async Task RevokeSessionAdmin_ShouldRevokeTargetUsersSession() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeSessionAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-revoke-admin-{Guid.NewGuid():N}"); @@ -405,7 +405,7 @@ await LoginAsync( chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -415,23 +415,19 @@ await LoginAsync( var sessionId = chain.ActiveSessionId!.Value; - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/{sessionId.Value}/revoke", null); revoke.StatusCode.Should().Be(HttpStatusCode.OK); - // // Revoked session can no longer authenticate. - // var targetVerification = await GetChainsAsync(targetClient); targetVerification.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - // // Admin's own authority must remain intact. - // var adminVerification = await GetChainsAsync(adminClient); adminVerification.StatusCode.Should() @@ -443,15 +439,15 @@ public async Task RevokeSessionAdmin_ShouldRejectSessionOwnedByDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var targetA = await _factory.CreateLoginUserAsync(); - var targetB = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetA = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetB = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeSessionAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-revoke-cross-admin-{Guid.NewGuid():N}"); @@ -492,7 +488,7 @@ await LoginAsync( bChainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var bPage = await bChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); bPage.Should().NotBeNull(); @@ -507,7 +503,7 @@ await LoginAsync( // var attack = await adminClient.PostAsync( $"/auth/admin/users/{targetA.UserKey.Value}/sessions/{bSessionId.Value}/revoke", - null); + null, TestContext.Current.CancellationToken); attack.StatusCode.Should().BeOneOf( HttpStatusCode.BadRequest, @@ -531,8 +527,8 @@ public async Task RevokeSessionAdmin_ShouldRequirePermission() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"session-revoke-no-permission-{Guid.NewGuid():N}"); @@ -558,7 +554,7 @@ await LoginAsync( var chainsResponse = await GetChainsAsync(targetClient); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -568,7 +564,7 @@ await LoginAsync( sessionId.Should().NotBeNull(); - var revoke = await actorClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/{sessionId!.Value.Value}/revoke", null); @@ -590,14 +586,14 @@ public async Task RevokeSessionAdmin_ShouldNotAffectOtherSessionOfSameUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeSessionAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"session-single-admin-{Guid.NewGuid():N}"); @@ -636,7 +632,7 @@ await LoginAsync( var chainsResponse = await GetChainsAsync(targetClient1); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -644,7 +640,7 @@ await LoginAsync( chain1.ActiveSessionId.Should().NotBeNull(); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/{chain1.ActiveSessionId!.Value.Value}/revoke", null); @@ -680,14 +676,14 @@ public async Task RevokeChainAdmin_ShouldRevokeTargetChain() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeChainAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"revoke-chain-admin-{Guid.NewGuid():N}"); @@ -715,14 +711,14 @@ await LoginAsync( chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); var targetChain = page!.Items.Single(x => x.IsCurrentDevice); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}/revoke", null); @@ -752,15 +748,15 @@ public async Task RevokeChainAdmin_ShouldRejectChainOwnedByDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var targetA = await _factory.CreateLoginUserAsync(); - var targetB = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetA = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var targetB = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeChainAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"revoke-chain-cross-admin-{Guid.NewGuid():N}"); @@ -800,7 +796,7 @@ await LoginAsync( .Be(HttpStatusCode.OK); var bPage = await bChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); bPage.Should().NotBeNull(); @@ -813,7 +809,7 @@ await LoginAsync( // var attack = await adminClient.PostAsync( $"/auth/admin/users/{targetA.UserKey.Value}/sessions/chains/{bChain.ChainId.Value}/revoke", - null); + null, TestContext.Current.CancellationToken); attack.StatusCode.Should().BeOneOf( HttpStatusCode.BadRequest, @@ -847,14 +843,14 @@ public async Task RevokeChainAdmin_ShouldNotAffectOtherChainOfSameUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, [ UAuthActions.Sessions.RevokeChainAdmin - ]); + ], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"revoke-chain-isolation-admin-{Guid.NewGuid():N}"); @@ -894,7 +890,7 @@ await LoginAsync( await GetChainsAsync(targetClient1); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -903,7 +899,7 @@ await LoginAsync( var chain1 = page.Items.Single(x => x.IsCurrentDevice); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{chain1.ChainId.Value}/revoke", null); @@ -943,8 +939,8 @@ public async Task RevokeChainAdmin_ShouldRequirePermissionWithoutMutation() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"revoke-chain-denied-actor-{Guid.NewGuid():N}"); @@ -971,14 +967,14 @@ await LoginAsync( await GetChainsAsync(targetClient); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); var targetChain = page!.Items.Single(x => x.IsCurrentDevice); - var response = await actorClient.PostAsync( + var response = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}/revoke", null); @@ -1001,12 +997,12 @@ public async Task RevokeAllChainsAdmin_ShouldRevokeAllTargetUsersChains() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Sessions.RevokeAllChainsAdmin]); + [UAuthActions.Sessions.RevokeAllChainsAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"revoke-all-admin-{Guid.NewGuid():N}"); @@ -1048,7 +1044,7 @@ await LoginAsync( (await GetChainsAsync(targetClient2)) .StatusCode.Should().Be(HttpStatusCode.OK); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", null); @@ -1075,13 +1071,13 @@ public async Task RevokeAllChainsAdmin_ShouldNotAffectDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); - var unrelated = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelated = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Sessions.RevokeAllChainsAdmin]); + [UAuthActions.Sessions.RevokeAllChainsAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"revoke-all-isolation-admin-{Guid.NewGuid():N}"); @@ -1114,7 +1110,7 @@ await LoginAsync( targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); unrelatedClient.DefaultRequestHeaders.Add("Cookie", unrelatedCookie); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", null); @@ -1144,8 +1140,8 @@ public async Task RevokeAllChainsAdmin_ShouldRequirePermissionWithoutMutation() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"revoke-all-denied-actor-{Guid.NewGuid():N}"); @@ -1178,7 +1174,7 @@ await LoginAsync( targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); - var response = await actorClient.PostAsync( + var response = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", null); @@ -1201,7 +1197,7 @@ public async Task RevokeAllChainsAdmin_ShouldRejectUnauthenticatedRequestWithout { _factory.Clock.Reset(); - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var anonymousClient = CreateClient( $"revoke-all-anonymous-{Guid.NewGuid():N}"); @@ -1219,16 +1215,14 @@ await LoginAsync( "Cookie", targetCookie); - var response = await anonymousClient.PostAsync( + var response = await CsrfTestClient.PostAsync(anonymousClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", null); response.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - // // Anonymous request must produce zero mutation. - // var verification = await GetChainsAsync(targetClient); @@ -1241,12 +1235,12 @@ public async Task RevokeRootAdmin_ShouldInvalidateAllExistingTargetSessions() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Sessions.RevokeRootAdmin]); + [UAuthActions.Sessions.RevokeRootAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"root-admin-{Guid.NewGuid():N}"); @@ -1285,24 +1279,20 @@ await LoginAsync( (await GetChainsAsync(targetClient2)) .StatusCode.Should().Be(HttpStatusCode.OK); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); revoke.StatusCode.Should().Be(HttpStatusCode.OK); - // // Root cascade must invalidate every existing target session. - // (await GetChainsAsync(targetClient1)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); (await GetChainsAsync(targetClient2)) .StatusCode.Should().Be(HttpStatusCode.Unauthorized); - // // Actor remains valid. - // (await GetChainsAsync(adminClient)) .StatusCode.Should().Be(HttpStatusCode.OK); } @@ -1312,13 +1302,13 @@ public async Task RevokeRootAdmin_ShouldNotAffectDifferentUser() { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); - var unrelated = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelated = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Sessions.RevokeRootAdmin]); + [UAuthActions.Sessions.RevokeRootAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient( $"root-isolation-admin-{Guid.NewGuid():N}"); @@ -1351,7 +1341,7 @@ await LoginAsync( targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); unrelatedClient.DefaultRequestHeaders.Add("Cookie", unrelatedCookie); - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); @@ -1372,8 +1362,8 @@ public async Task RevokeRootAdmin_ShouldRequirePermissionWithoutMutation() { _factory.Clock.Reset(); - var actor = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var actorClient = CreateClient( $"root-denied-actor-{Guid.NewGuid():N}"); @@ -1392,7 +1382,7 @@ await LoginAsync( actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); - var response = await actorClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + var response = await CsrfTestClient.PostAsync(actorClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized); @@ -1404,7 +1394,7 @@ public async Task RevokeRootAdmin_ShouldRejectUnauthenticatedRequestWithoutMutat { _factory.Clock.Reset(); - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var anonymousClient = CreateClient($"root-anonymous-{Guid.NewGuid():N}"); using var targetClient = CreateClient($"root-anonymous-target-{Guid.NewGuid():N}"); @@ -1412,7 +1402,7 @@ public async Task RevokeRootAdmin_ShouldRejectUnauthenticatedRequestWithoutMutat var targetCookie = GetSessionCookie(await LoginAsync(targetClient, target.Identifier, target.Secret)); targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); - var response = await anonymousClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + var response = await CsrfTestClient.PostAsync(anonymousClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); (await GetChainsAsync(targetClient)).StatusCode.Should().Be(HttpStatusCode.OK); @@ -1423,10 +1413,10 @@ public async Task RevokeRootAdmin_ShouldAllowSubsequentLoginWithNewAuthenticatio { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); - await _factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Sessions.RevokeRootAdmin]); + await _factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Sessions.RevokeRootAdmin], TestContext.Current.CancellationToken); var adminDevice = $"root-recreate-admin-{Guid.NewGuid():N}"; @@ -1455,7 +1445,7 @@ public async Task RevokeRootAdmin_ShouldAllowSubsequentLoginWithNewAuthenticatio beforeRevoke.StatusCode.Should().Be(HttpStatusCode.OK); - var revoke = await adminClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); revoke.StatusCode.Should().Be(HttpStatusCode.OK); @@ -1489,12 +1479,12 @@ public async Task RevokeRootAdmin_ConcurrentSubsequentLogins_ShouldCreateSingleA { _factory.Clock.Reset(); - var admin = await _factory.CreateLoginUserAsync(); - var target = await _factory.CreateLoginUserAsync(); + var admin = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await _factory.GrantPermissionsAsync( admin.UserKey, - [UAuthActions.Sessions.RevokeRootAdmin]); + [UAuthActions.Sessions.RevokeRootAdmin], TestContext.Current.CancellationToken); using var adminClient = CreateClient($"root-concurrent-admin-{Guid.NewGuid():N}"); @@ -1502,9 +1492,7 @@ await _factory.GrantPermissionsAsync( using var initialTargetClient = CreateClient($"root-concurrent-initial-{Guid.NewGuid():N}"); - // // Establish initial authentication root. - // var adminLogin = await LoginAsync( adminClient, admin.Identifier, @@ -1529,10 +1517,8 @@ await _factory.GrantPermissionsAsync( "Cookie", initialCookie); - // // Revoke the target's current authentication root. - // - var revoke = await adminClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(adminClient, $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); @@ -1544,10 +1530,7 @@ await _factory.GrantPermissionsAsync( oldSessionVerification.StatusCode .Should().Be(HttpStatusCode.Unauthorized); - // - // Two devices authenticate concurrently after there is - // no active root. - // + // Two devices authenticate concurrently after there is no active root. using var clientA = CreateClient($"root-concurrent-a-{Guid.NewGuid():N}"); @@ -1596,7 +1579,7 @@ await _factory.GrantPermissionsAsync( verificationB.StatusCode.Should().Be(HttpStatusCode.OK); var chainsA = await verificationA.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chainsA.Should().NotBeNull(); @@ -1609,7 +1592,7 @@ await _factory.GrantPermissionsAsync( .Should().Be(1); var chainsB = await verificationB.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); chainsB.Should().NotBeNull(); @@ -1667,7 +1650,7 @@ private HttpClient CreateClient(string deviceId) private static Task LoginAsync(HttpClient client, string identifier, string secret) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, "/auth/login", new { @@ -1696,7 +1679,7 @@ private static string GetSessionCookie(HttpResponseMessage response) private static Task GetChainsAsync(HttpClient client) { - return client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs index fb0fc9dd..5f84aba7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs @@ -132,7 +132,7 @@ public async Task IdentifierGet_ExpiredIdleSession_ShouldReturnUnauthorized(stri o.Session.TouchInterval = TimeSpan.FromSeconds(5); }); - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = factory.CreateClient( new WebApplicationFactoryClientOptions @@ -141,29 +141,29 @@ public async Task IdentifierGet_ExpiredIdleSession_ShouldReturnUnauthorized(stri HandleCookies = false }); - client.DefaultRequestHeaders.Add( - "Origin", "https://localhost:6130"); - client.DefaultRequestHeaders.Add( - "X-UDID", $"idle-regression-{Guid.NewGuid():N}"); - client.DefaultRequestHeaders.Add( - "X-UAuth-ClientProfile", profile); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", $"idle-regression-{Guid.NewGuid():N}"); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); - using var login = await client.PostAsJsonAsync( + using var login = await CsrfTestClient.PostJsonAsync( + client, "/auth/login", - new { identifier = user.Identifier, secret = user.Secret }); + new + { + identifier = user.Identifier, + secret = user.Secret + }); login.StatusCode.Should().Be(HttpStatusCode.Found); login.Headers.TryGetValues("Set-Cookie", out var cookies) .Should().BeTrue(); - client.DefaultRequestHeaders.Add( - "Cookie", - string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); + ApplyCookies(client, login); // Same session must be accepted before IdleTimeout. using var before = await client.PostAsJsonAsync( - "/auth/me/identifiers/get", new { }); + "/auth/me/identifiers/get", new { }, cancellationToken: TestContext.Current.CancellationToken); before.StatusCode.Should().Be(HttpStatusCode.OK); @@ -172,7 +172,7 @@ public async Task IdentifierGet_ExpiredIdleSession_ShouldReturnUnauthorized(stri // Keep the original cookie deliberately. using var after = await client.PostAsJsonAsync( - "/auth/me/identifiers/get", new { }); + "/auth/me/identifiers/get", new { }, cancellationToken: TestContext.Current.CancellationToken); after.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "an expired session must not remain authenticated " + @@ -388,11 +388,9 @@ public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph() using var secondClient = CloneClientWithCookies( factory, client, "BlazorWasm"); - using var first = await firstClient.PostAsync( - "/auth/refresh", null); + using var first = await CsrfTestClient.PostAsync(firstClient, "/auth/refresh", null); - using var second = await secondClient.PostAsync( - "/auth/refresh", null); + using var second = await CsrfTestClient.PostAsync(secondClient, "/auth/refresh", null); // A rotated refresh credential must not be reusable. first.StatusCode.Should().Be(HttpStatusCode.NoContent); @@ -470,17 +468,29 @@ private static HttpClient CreateClient(AuthServerFactory factory, string profile private static async Task Login(AuthServerFactory factory, HttpClient client) { var user = await factory.CreateLoginUserAsync(); - using var response = await client.PostAsJsonAsync("/auth/login", - new { identifier = user.Identifier, secret = user.Secret }); + + using var response = await CsrfTestClient.PostJsonAsync( + client, + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + response.StatusCode.Should().Be(HttpStatusCode.Found); ApplyCookies(client, response); } private static async Task Refresh(HttpClient client, string profile) { - var response = await client.PostAsync("/auth/refresh", null); + var response = await CsrfTestClient.PostAsync( + client, + "/auth/refresh"); + if (response.IsSuccessStatusCode && profile == "BlazorWasm") ApplyCookies(client, response); + return response; } @@ -508,8 +518,7 @@ private static string Cookie(HttpClient client, string name) => .Split("; ", StringSplitOptions.RemoveEmptyEntries) .Single(x => x.StartsWith(name + "=", StringComparison.Ordinal)); - private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrent( - AuthServerFactory factory, HttpClient client) + private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrent(AuthServerFactory factory, HttpClient client) { using var response = await client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest { PageNumber = 1, PageSize = 50 }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs index 65bb06c6..c029a1a3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs @@ -23,8 +23,8 @@ public async Task ListChainsSelf_ShouldReturnOnlyAuthenticatedUsersChains() { _factory.Clock.Reset(); - var user1 = await _factory.CreateLoginUserAsync(); - var user2 = await _factory.CreateLoginUserAsync(); + var user1 = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var user2 = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var user1Device1 = CreateClient( $"session-user1-device1-{Guid.NewGuid():N}"); @@ -57,7 +57,7 @@ await LoginAsync( response.StatusCode.Should().Be(HttpStatusCode.OK); var page = await response.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -70,7 +70,7 @@ public async Task GetChainSelf_ShouldReturnRequestedOwnedChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client = CreateClient( $"session-detail-{Guid.NewGuid():N}"); @@ -90,7 +90,7 @@ await LoginAsync( chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); var page = await chainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); page.Should().NotBeNull(); @@ -98,12 +98,12 @@ await LoginAsync( var detailResponse = await client.PostAsync( $"/auth/me/sessions/chains/{chain.ChainId.Value}", - null); + null, TestContext.Current.CancellationToken); detailResponse.StatusCode.Should().Be(HttpStatusCode.OK); var detail = await detailResponse.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); detail.Should().NotBeNull(); detail!.ChainId.Should().Be(chain.ChainId); @@ -116,8 +116,8 @@ public async Task GetChainSelf_ShouldNotAllowReadingAnotherUsersChain() { _factory.Clock.Reset(); - var attacker = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var attacker = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var attackerClient = CreateClient( $"session-attacker-{Guid.NewGuid():N}"); @@ -149,7 +149,7 @@ await LoginAsync( await GetChainsAsync(victimClient); var victimChains = await victimChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); victimChains.Should().NotBeNull(); @@ -158,7 +158,7 @@ await LoginAsync( var attack = await attackerClient.PostAsync( $"/auth/me/sessions/chains/{victimChain.ChainId.Value}", - null); + null, TestContext.Current.CancellationToken); attack.StatusCode.Should().BeOneOf( HttpStatusCode.BadRequest, @@ -179,7 +179,7 @@ public async Task RevokeChainSelf_ShouldRevokeOwnedTargetChain() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var currentDevice = $"session-current-{Guid.NewGuid():N}"; @@ -214,31 +214,27 @@ await LoginAsync( await GetChainsAsync(targetClient); var targetChains = await targetChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); targetChains.Should().NotBeNull(); var targetChain = targetChains!.Items.Single(x => x.IsCurrentDevice); - var revoke = await currentClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(currentClient, $"/auth/me/sessions/chains/{targetChain.ChainId.Value}/revoke", null); revoke.StatusCode.Should().Be(HttpStatusCode.OK); - // // Target session must immediately stop working. - // var targetVerification = await GetChainsAsync(targetClient); targetVerification.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - // // Actor's own chain must remain usable. - // var actorVerification = await GetChainsAsync(currentClient); @@ -251,8 +247,8 @@ public async Task RevokeChainSelf_ShouldNotAllowRevokingAnotherUsersChain() { _factory.Clock.Reset(); - var attacker = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var attacker = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var attackerClient = CreateClient( $"session-revoke-attacker-{Guid.NewGuid():N}"); @@ -284,7 +280,7 @@ await LoginAsync( await GetChainsAsync(victimClient); var victimChains = await victimChainsResponse.Content - .ReadFromJsonAsync>(); + .ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); victimChains.Should().NotBeNull(); @@ -293,7 +289,7 @@ await LoginAsync( var attack = await attackerClient.PostAsync( $"/auth/me/sessions/chains/{victimChain.ChainId.Value}/revoke", - null); + null, TestContext.Current.CancellationToken); attack.StatusCode.Should().BeOneOf( HttpStatusCode.BadRequest, @@ -327,7 +323,7 @@ public async Task SessionSelfEndpoints_ShouldRejectUnauthenticatedRequests() using var client = CreateClient( $"session-anonymous-{Guid.NewGuid():N}"); - var list = await client.PostAsJsonAsync( + var list = await CsrfTestClient.PostJsonAsync(client, "/auth/me/sessions/chains", new PageRequest()); @@ -338,26 +334,26 @@ public async Task SessionSelfEndpoints_ShouldRejectUnauthenticatedRequests() var detail = await client.PostAsync( $"/auth/me/sessions/chains/{randomChain.Value}", - null); + null, TestContext.Current.CancellationToken); detail.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - var revoke = await client.PostAsync( + var revoke = await CsrfTestClient.PostAsync(client, $"/auth/me/sessions/chains/{randomChain.Value}/revoke", null); revoke.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - var revokeOthers = await client.PostAsync( + var revokeOthers = await CsrfTestClient.PostAsync(client, "/auth/me/sessions/revoke-others", null); revokeOthers.StatusCode.Should() .Be(HttpStatusCode.Unauthorized); - var revokeAll = await client.PostAsync( + var revokeAll = await CsrfTestClient.PostAsync(client, "/auth/me/sessions/revoke-all", null); @@ -370,7 +366,7 @@ public async Task RevokeOthersSelf_ShouldRevokeAllOtherChainsButKeepCurrentChain { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var currentClient = CreateClient( $"revoke-others-current-{Guid.NewGuid():N}"); @@ -411,7 +407,7 @@ await LoginAsync( "Cookie", otherCookie2); - var revoke = await currentClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(currentClient, "/auth/me/sessions/revoke-others", null); @@ -447,8 +443,8 @@ public async Task RevokeOthersSelf_ShouldNotAffectAnotherUsersChains() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); - var unrelatedUser = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelatedUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var currentClient = CreateClient( $"revoke-others-owner-current-{Guid.NewGuid():N}"); @@ -489,7 +485,7 @@ await LoginAsync( "Cookie", unrelatedCookie); - var revoke = await currentClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(currentClient, "/auth/me/sessions/revoke-others", null); @@ -528,7 +524,7 @@ public async Task RevokeAllSelf_ShouldRevokeCurrentAndAllOtherChains() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var client1 = CreateClient( $"revoke-all-1-{Guid.NewGuid():N}"); @@ -561,10 +557,8 @@ await LoginAsync( client2.DefaultRequestHeaders.Add("Cookie", cookie2); client3.DefaultRequestHeaders.Add("Cookie", cookie3); - // // Client1 revokes every chain, including itself. - // - var revoke = await client1.PostAsync( + var revoke = await CsrfTestClient.PostAsync(client1, "/auth/me/sessions/revoke-all", null); @@ -589,8 +583,8 @@ public async Task RevokeAllSelf_ShouldNotAffectAnotherUser() { _factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(); - var unrelatedUser = await _factory.CreateLoginUserAsync(); + var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var unrelatedUser = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var userClient = CreateClient( $"revoke-all-owner-{Guid.NewGuid():N}"); @@ -618,7 +612,7 @@ await LoginAsync( "Cookie", unrelatedCookie); - var revoke = await userClient.PostAsync( + var revoke = await CsrfTestClient.PostAsync(userClient, "/auth/me/sessions/revoke-all", null); @@ -664,12 +658,10 @@ private HttpClient CreateClient(string deviceId) return client; } - private static async Task LoginAsync( - HttpClient client, - string identifier, - string secret) + private static Task LoginAsync(HttpClient client, string identifier, string secret) { - return await client.PostAsJsonAsync( + return CsrfTestClient.PostJsonAsync( + client, "/auth/login", new { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs index f657bc1d..8ee068c6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs @@ -20,7 +20,7 @@ public UAuthHubSampleSmokeTests(AuthServerFactory factory) [InlineData("/login")] public async Task CriticalPages_ShouldRenderWithoutServerError(string path) { - var response = await _client.GetAsync(path); + var response = await _client.GetAsync(path, TestContext.Current.CancellationToken); ((int)response.StatusCode) .Should() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs index 071e052c..10e528a9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs @@ -72,7 +72,7 @@ public async Task CreateUser_WhenPersistenceFails_ShouldRollbackAllUserState() var exception = await Assert.ThrowsAsync( () => service.CreateUserAsync( context, - request)); + request, TestContext.Current.CancellationToken)); exception.Message.Should() .Be("simulated_identifier_store_failure"); @@ -103,11 +103,11 @@ public async Task CreateUser_WhenPersistenceFails_ShouldRollbackAllUserState() var identifierStore = identifierFactory.Create(tenant); - var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey), TestContext.Current.CancellationToken); - var profiles = await profileStore.GetAllProfilesByUserAsync(userKey); + var profiles = await profileStore.GetAllProfilesByUserAsync(userKey, TestContext.Current.CancellationToken); - var identifiers = await identifierStore.GetByUserAsync(userKey); + var identifiers = await identifierStore.GetByUserAsync(userKey, TestContext.Current.CancellationToken); lifecycle.Should().BeNull("failed user creation must not leave a lifecycle aggregate"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs index 82ce3a0f..ab0d052a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -35,11 +35,11 @@ public async Task Availability_WhenUsernameDoesNotExist_ShouldReturnAvailable() { Type = UserIdentifierType.Username, Value = username - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -64,7 +64,7 @@ public async Task Availability_WhenUsernameAlreadyExists_ShouldReturnUnavailable UserName = username, Password = $"Test-{Guid.NewGuid():N}!", DisplayName = username - }); + }, cancellationToken: TestContext.Current.CancellationToken); create.StatusCode.Should().Be(HttpStatusCode.OK); @@ -74,11 +74,11 @@ public async Task Availability_WhenUsernameAlreadyExists_ShouldReturnUnavailable { Type = UserIdentifierType.Username, Value = username - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -105,7 +105,7 @@ public async Task Availability_ShouldUseNormalizedIdentifier() Email = email, Password = $"Test-{Guid.NewGuid():N}!", DisplayName = "Availability Test" - }); + }, cancellationToken: TestContext.Current.CancellationToken); create.StatusCode.Should().Be(HttpStatusCode.OK); @@ -116,11 +116,11 @@ public async Task Availability_ShouldUseNormalizedIdentifier() { Type = UserIdentifierType.Email, Value = $" {email.ToUpperInvariant()} " - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -144,14 +144,14 @@ public async Task Availability_WhenIdentifierIsInvalid_ShouldReturnValidationRes { Type = UserIdentifierType.Email, Value = "not-an-email" - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -173,14 +173,14 @@ public async Task Availability_WhenIdentifierIsEmpty_ShouldReturnValidationResul { Type = UserIdentifierType.Username, Value = " " - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -200,13 +200,12 @@ await response.Content [InlineData("unset-primary", true)] [InlineData("verify", true)] [InlineData("delete", true)] - public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotFoundAndPreserveRecords( - string operation, bool admin) + public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotFoundAndPreserveRecords(string operation, bool admin) { - var actor = await _factory.CreateLoginUserAsync(); - var victim = await _factory.CreateLoginUserAsync(); + var actor = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var victim = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); if (admin) - await _factory.GrantPermissionsAsync(actor.UserKey, new[] { "*" }); + await _factory.GrantPermissionsAsync(actor.UserKey, new[] { "*" }, TestContext.Current.CancellationToken); using var scope = _factory.Services.CreateScope(); var services = scope.ServiceProvider; @@ -221,16 +220,16 @@ public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotF _factory.Clock.UtcNow, isPrimary: operation == "unset-primary", verifiedAt: operation == "verify" ? null : _factory.Clock.UtcNow); - await store.AddAsync(identifier); + await store.AddAsync(identifier, TestContext.Current.CancellationToken); - var before = (await store.GetByUserAsync(victim.UserKey)) + var before = (await store.GetByUserAsync(victim.UserKey, TestContext.Current.CancellationToken)) .Select(x => x.Snapshot()).ToArray(); using var client = CreateClient(); await AuthenticateIdentifierClientAsync(client, actor.Identifier, actor.Secret); // Prove authentication works before exercising the ownership boundary. using var authenticated = await client.PostAsJsonAsync( - "/auth/me/identifiers/get", new { }); + "/auth/me/identifiers/get", new { }, cancellationToken: TestContext.Current.CancellationToken); authenticated.StatusCode.Should().Be(HttpStatusCode.OK); var path = admin @@ -241,18 +240,19 @@ public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotF // Use a separate route target whose identifiers are NOT the victim's. if (admin) { - var target = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); path = $"/auth/admin/users/{target.UserKey.Value}/identifiers/{operation}"; } - using var response = await client.PostAsJsonAsync(path, new - { - id = identifier.Id, - newValue = $"changed-{Guid.NewGuid():N}@example.com", - mode = 0 // DeleteMode.Soft - }); + using var response = await CsrfTestClient.PostJsonAsync(client, path, + new + { + id = identifier.Id, + newValue = $"changed-{Guid.NewGuid():N}@example.com", + mode = 0 + }); - var after = await store.GetByUserAsync(victim.UserKey); + var after = await store.GetByUserAsync(victim.UserKey, TestContext.Current.CancellationToken); using var assertions = new AwesomeAssertions.Execution.AssertionScope(); response.StatusCode.Should().Be(HttpStatusCode.NotFound); after.Should().BeEquivalentTo(before, @@ -274,7 +274,7 @@ public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string op var factory = configuredFactory ?? _factory; - var owner = await factory.CreateLoginUserAsync(); + var owner = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var scope = factory.Services.CreateScope(); var services = scope.ServiceProvider; var store = services.GetRequiredService().Create(TenantKeys.Single); @@ -287,7 +287,7 @@ public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string op factory.Clock.UtcNow, isPrimary: operation == "unset-primary", verifiedAt: operation == "verify" ? null : factory.Clock.UtcNow); - await store.AddAsync(identifier); + await store.AddAsync(identifier, TestContext.Current.CancellationToken); using var client = factory.CreateClient( new WebApplicationFactoryClientOptions { @@ -302,7 +302,8 @@ public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string op "X-UDID", $"identifier-owner-{Guid.NewGuid():N}"); await AuthenticateIdentifierClientAsync(client, owner.Identifier, owner.Secret); var newValue = $"updated-{Guid.NewGuid():N}@example.com"; - using var response = await client.PostAsJsonAsync( + using var response = await CsrfTestClient.PostJsonAsync( + client, $"/auth/me/identifiers/{operation}", new { @@ -314,8 +315,8 @@ public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string op : null }); response.IsSuccessStatusCode.Should().BeTrue( - $"own identifier operation should succeed: {await response.Content.ReadAsStringAsync()}"); - var saved = await store.GetByIdAsync(identifier.Id); + $"own identifier operation should succeed: {await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)}"); + var saved = await store.GetByIdAsync(identifier.Id, TestContext.Current.CancellationToken); saved.Should().NotBeNull(); switch (operation) { @@ -361,7 +362,7 @@ private HttpClient CreateClient() private static async Task AuthenticateIdentifierClientAsync(HttpClient client, string identifier, string secret) { - using var login = await client.PostAsJsonAsync("/auth/login", new { identifier, secret }); + using var login = await CsrfTestClient.PostJsonAsync(client, "/auth/login", new { identifier, secret }); login.StatusCode.Should().Be(HttpStatusCode.Found); login.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index af287e0e..f659f431 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -12,6 +12,7 @@ using Moq; using System.Net; using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Integration; @@ -41,13 +42,13 @@ public async Task CreateUser_ShouldCreateUsableUser() UserName = username, Password = secret, DisplayName = "Integration User" - }); + }, cancellationToken: TestContext.Current.CancellationToken); response.StatusCode.Should() .Be(HttpStatusCode.OK); var result = - await response.Content.ReadFromJsonAsync(); + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsSuccess.Should().BeTrue(); @@ -56,7 +57,8 @@ public async Task CreateUser_ShouldCreateUsableUser() // // The aggregate must be usable, not merely persisted. // - var login = await client.PostAsJsonAsync( + var login = await CsrfTestClient.PostJsonAsync( + client, "/auth/login", new { @@ -64,6 +66,8 @@ public async Task CreateUser_ShouldCreateUsableUser() secret }); + login.StatusCode.Should().Be(HttpStatusCode.Found); + login.StatusCode.Should() .Be(HttpStatusCode.Found); } @@ -96,7 +100,7 @@ public async Task CreateUser_ShouldCreateLifecycle() var lifecycle = await store.GetAsync( new UserLifecycleKey( TenantKeys.Single, - userKey)); + userKey), TestContext.Current.CancellationToken); lifecycle.Should().NotBeNull(); @@ -147,7 +151,7 @@ public async Task CreateUser_ShouldCreateDefaultProfile() new UserProfileKey( TenantKeys.Single, userKey, - ProfileKey.Default)); + ProfileKey.Default), TestContext.Current.CancellationToken); profile.Should().NotBeNull(); @@ -192,7 +196,7 @@ public async Task CreateUser_WithUsername_ShouldCreatePrimaryUsername() var identifiers = await store.GetByUserAsync( - userKey); + userKey, TestContext.Current.CancellationToken); var identifier = identifiers .Single(x => @@ -229,7 +233,7 @@ public async Task CreateUser_WithVerifiedEmail_ShouldNotPersistEmailAsVerified() using var scope = _factory.Services.CreateScope(); var factory = scope.ServiceProvider.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var identifiers = await store.GetByUserAsync(userKey); + var identifiers = await store.GetByUserAsync(userKey, TestContext.Current.CancellationToken); var identifier = identifiers.Single(x => x.Type == UserIdentifierType.Email); identifier.Value.Should().Be(email); @@ -271,7 +275,7 @@ public async Task CreateUser_WithUnverifiedEmail_ShouldPersistEmailAsUnverified( var identifiers = await store.GetByUserAsync( - userKey); + userKey, TestContext.Current.CancellationToken); var identifier = identifiers .Single(x => @@ -316,7 +320,7 @@ public async Task CreateUser_WithUsernameAndEmail_ShouldCreateBothIdentifiers() var identifiers = await store.GetByUserAsync( - userKey); + userKey, TestContext.Current.CancellationToken); identifiers.Should() .ContainSingle(x => @@ -354,7 +358,8 @@ await CreateUserAsync( // lifecycle integration produced a credential that // the authentication pipeline can consume. // - var login = await client.PostAsJsonAsync( + var login = await CsrfTestClient.PostJsonAsync( + client, "/auth/login", new { @@ -362,6 +367,8 @@ await CreateUserAsync( secret }); + login.StatusCode.Should().Be(HttpStatusCode.Found); + login.StatusCode.Should() .Be(HttpStatusCode.Found); @@ -387,7 +394,7 @@ public async Task CreateUser_WithExistingUsername_ShouldNotCreateSecondUser() var firstResult = await firstResponse.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); firstResult.Should().NotBeNull(); firstResult!.IsSuccess.Should().BeTrue(); @@ -408,7 +415,7 @@ await CreateUserResponseAsync( ((int)secondResponse.StatusCode).Should().BeInRange(400, 499); - var problem = await secondResponse.Content.ReadFromJsonAsync(); + var problem = await secondResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); problem.Should().NotBeNull(); @@ -439,7 +446,7 @@ await CreateUserResponseAsync( var identifier = await store.GetAsync( UserIdentifierType.Username, - normalized.Normalized); + normalized.Normalized, TestContext.Current.CancellationToken); identifier.Should().NotBeNull(); @@ -488,7 +495,7 @@ await lifecycleFactory .GetAsync( new UserLifecycleKey( TenantKeys.Single, - userKey)); + userKey), TestContext.Current.CancellationToken); var profile = await profileFactory @@ -497,13 +504,13 @@ await profileFactory new UserProfileKey( TenantKeys.Single, userKey, - ProfileKey.Default)); + ProfileKey.Default), TestContext.Current.CancellationToken); var identifiers = await identifierFactory .Create(TenantKeys.Single) .GetByUserAsync( - userKey); + userKey, TestContext.Current.CancellationToken); lifecycle.Should().NotBeNull(); profile.Should().NotBeNull(); @@ -561,7 +568,7 @@ public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() foreach (var name in names) { - var user = await factory.CreateLoginUserAsync(); + var user = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); using var seedScope = factory.Services.CreateScope(); @@ -576,7 +583,7 @@ await profileFactory user.UserKey, ProfileKey.Default, factory.Clock.UtcNow, - displayName: $"{prefix}-{name}")); + displayName: $"{prefix}-{name}"), TestContext.Current.CancellationToken); } using var scope = factory.Services.CreateScope(); @@ -597,6 +604,7 @@ await profileFactory targetUserKey: actor, resourceTenant: TenantKeys.Single, action: "test", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); // DefaultPageSize = 2 @@ -608,7 +616,7 @@ await profileFactory PageSize = 0, Search = prefix, SortBy = nameof(UserSummary.DisplayName) - }); + }, TestContext.Current.CancellationToken); firstPage.TotalCount.Should().Be(5); firstPage.PageNumber.Should().Be(1); @@ -629,7 +637,7 @@ await profileFactory PageSize = 100, Search = prefix, SortBy = nameof(UserSummary.DisplayName) - }); + }, TestContext.Current.CancellationToken); secondPage.TotalCount.Should().Be(5); secondPage.PageNumber.Should().Be(2); @@ -649,7 +657,8 @@ public async Task AdminCreate_WhenAnonymous_ShouldReturnUnauthorized() using var client = CreateClient(); - var response = await client.PostAsJsonAsync( + var response = await CsrfTestClient.PostJsonAsync( + client, "/auth/admin/users/create", new CreateUserRequest { @@ -749,4 +758,4 @@ private static UserKey GetUserKey(UserCreateResult result) return UserKey.FromString(result.UserKey!); } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs index 45dd55d9..5fa6cb94 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -7,48 +7,49 @@ namespace CodeBeam.UltimateAuth.Tests.Integration; -public class UserProfileTests : IClassFixture +public class UserProfileTests : IDisposable { + // Profiles created by one test must not exist in another test's host. + private readonly AuthServerFactory _factory = new(); private readonly HttpClient _client; - public UserProfileTests(AuthServerFactory factory) + public UserProfileTests() { - _client = factory.CreateClient(new WebApplicationFactoryClientOptions + _client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); - _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + _client.DefaultRequestHeaders.Add("X-UDID", $"profile-test-{Guid.NewGuid():N}"); + } + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); } [Fact] public async Task Profile_Switch_Should_Return_Correct_Profile_Data() { - var loginResponse = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + await Login(); - var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); - - var defaultResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + var defaultResponse = await PostProfileAsync("/auth/me/profile/get", new { }); defaultResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var defaultProfile = await defaultResponse.Content.ReadFromJsonAsync(); + var defaultProfile = await defaultResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); defaultProfile.Should().NotBeNull(); - var createResponse = await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + var createResponse = await PostProfileAsync("/auth/me/profile/create", new CreateProfileRequest { ProfileKey = ProfileKey.Parse("business", null) }); createResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var updateResponse = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest() + var updateResponse = await PostProfileAsync("/auth/me/profile/update", new UpdateProfileRequest() { ProfileKey = ProfileKey.Parse("business", null), DisplayName = "Updated Business Name" @@ -56,21 +57,21 @@ public async Task Profile_Switch_Should_Return_Correct_Profile_Data() updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var businessResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() + var businessResponse = await PostProfileAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = ProfileKey.Parse("business", null) }); businessResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var businessProfile = await businessResponse.Content.ReadFromJsonAsync(); + var businessProfile = await businessResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); businessProfile.Should().NotBeNull(); businessProfile!.DisplayName.Should().Be("Updated Business Name"); - var defaultAgainResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + var defaultAgainResponse = await PostProfileAsync("/auth/me/profile/get", new { }); - var defaultAgain = await defaultAgainResponse.Content.ReadFromJsonAsync(); + var defaultAgain = await defaultAgainResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); defaultAgain!.DisplayName.Should().Be(defaultProfile!.DisplayName); } @@ -78,18 +79,11 @@ public async Task Profile_Switch_Should_Return_Correct_Profile_Data() [Fact] public async Task GetMe_Without_ProfileKey_Should_Return_Default_Profile() { - var login = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + await Login(); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }, cancellationToken: TestContext.Current.CancellationToken); - var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); - - var profile = await response.Content.ReadFromJsonAsync(); + var profile = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); profile.Should().NotBeNull(); profile!.ProfileKey.Value.Should().Be("default"); @@ -98,16 +92,9 @@ public async Task GetMe_Without_ProfileKey_Should_Return_Default_Profile() [Fact] public async Task Should_Not_Found_NonDefault_Profile_When_Not_Created() { - var login = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); - - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + await Login(); - var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new + var response = await PostProfileAsync("/auth/me/profile/get", new { profileKey = "business" }); @@ -130,21 +117,19 @@ public async Task Should_Not_Create_Duplicate_Profile() ProfileKey = key }; - var first = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + var first = await PostProfileAsync("/auth/me/profile/create", request); first.StatusCode.Should().Be(HttpStatusCode.OK); - var second = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + var second = await PostProfileAsync("/auth/me/profile/create", request); second.StatusCode.Should().Be(HttpStatusCode.Conflict); } [Fact] public async Task Should_Not_Delete_Default_Profile() { - var login = await Login(); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + await Login(); - var response = await _client.PostAsJsonAsync("/auth/me/profile/delete", new + var response = await PostProfileAsync("/auth/me/profile/delete", new { profileKey = "default" }); @@ -155,11 +140,9 @@ public async Task Should_Not_Delete_Default_Profile() [Fact] public async Task Should_Not_Update_NonExisting_Profile() { - var login = await Login(); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + await Login(); - var response = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest + var response = await PostProfileAsync("/auth/me/profile/update", new UpdateProfileRequest { ProfileKey = ProfileKey.Parse("ghost", null), DisplayName = "Should Fail" @@ -171,23 +154,21 @@ public async Task Should_Not_Update_NonExisting_Profile() [Fact] public async Task Deleted_Profile_Should_Not_Be_Returned() { - var login = await Login(); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + await Login(); var key = ProfileKey.Parse("business", null); - await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + await PostProfileAsync("/auth/me/profile/create", new CreateProfileRequest { ProfileKey = key }); - await _client.PostAsJsonAsync("/auth/me/profile/delete", new + await PostProfileAsync("/auth/me/profile/delete", new { profileKey = key.Value }); - var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest + var response = await PostProfileAsync("/auth/me/profile/get", new GetProfileRequest { ProfileKey = key }); @@ -198,19 +179,40 @@ public async Task Deleted_Profile_Should_Not_Be_Returned() private async Task Login() { - var response = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var response = await CsrfTestClient.PostJsonAsync( + _client, + "/auth/login", + new + { + identifier = "admin", + secret = "admin" + }); response.StatusCode.Should().Be(HttpStatusCode.Found); - var cookie = response.Headers.GetValues("Set-Cookie").First(); + response.Headers.TryGetValues( + "Set-Cookie", + out var cookies).Should().BeTrue(); + + var sessionCookie = cookies! + .Single(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)) + .Split(';', 2)[0]; _client.DefaultRequestHeaders.Remove("Cookie"); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + _client.DefaultRequestHeaders.Add("Cookie", sessionCookie); return response; } + + private Task PostProfileAsync( + string url, + T request) + { + return CsrfTestClient.PostJsonAsync( + _client, + url, + request); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj b/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj new file mode 100644 index 00000000..104a4c4e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj @@ -0,0 +1,22 @@ + + + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs index c7fc3b1f..0378b5a6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs @@ -1 +1,3 @@ -ο»Ώ[assembly: CollectionBehavior(DisableTestParallelization = true)] +ο»Ώusing Xunit.v3; + +[assembly: Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs index b3523c18..25117b45 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs @@ -31,12 +31,12 @@ public async Task AddAsync_WhenValid_PersistsState() var state = AuthenticationSecurityState.CreateAccount(Tenant, user); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(state.Id); @@ -64,18 +64,18 @@ public async Task AddAsync_SameUserWithAccountAndFactorStates_IsAllowed() user, CredentialType.Password); - await store.AddAsync(account); - await store.AddAsync(factor); + await store.AddAsync(account, ct: TestContext.Current.CancellationToken); + await store.AddAsync(factor, ct: TestContext.Current.CancellationToken); var accountResult = await store.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var factorResult = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); accountResult.Should().NotBeNull(); factorResult.Should().NotBeNull(); @@ -100,7 +100,7 @@ public async Task AddAsync_WhenSameLogicalStateAlreadyExists_ThrowsConflict() Tenant, user); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); var act = () => store.AddAsync(second); @@ -137,7 +137,7 @@ public async Task GetAsync_WhenStateDoesNotExist_ReturnsNull() var result = await store.GetAsync( UserKey.New(), AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -158,18 +158,18 @@ public async Task AddAsync_SameUserWithDifferentScope_IsAllowed() var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); var firstResult = await store.GetAsync( user, first.Scope, - first.CredentialType); + first.CredentialType, ct: TestContext.Current.CancellationToken); var secondResult = await store.GetAsync( user, second.Scope, - second.CredentialType); + second.CredentialType, ct: TestContext.Current.CancellationToken); firstResult.Should().NotBeNull(); secondResult.Should().NotBeNull(); @@ -190,18 +190,18 @@ public async Task AddAsync_SameUserAndScopeWithDifferentCredentialType_IsAllowed var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); var password = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); var passkey = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Passkey); + CredentialType.Passkey, ct: TestContext.Current.CancellationToken); password.Should().NotBeNull(); passkey.Should().NotBeNull(); @@ -226,7 +226,7 @@ public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges() Tenant, user); - await store.AddAsync(original); + await store.AddAsync(original, ct: TestContext.Current.CancellationToken); var updated = original.RegisterFailure( Now, @@ -236,12 +236,12 @@ public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges() await store.UpdateAsync( updated, - expectedVersion: original.SecurityVersion); + expectedVersion: original.SecurityVersion, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -262,7 +262,7 @@ public async Task UpdateAsync_WhenExpectedVersionIsStale_ThrowsConflict() Tenant, UserKey.New()); - await store.AddAsync(original); + await store.AddAsync(original, ct: TestContext.Current.CancellationToken); var updated = original.RegisterFailure( Now, @@ -291,7 +291,7 @@ public async Task UpdateAsync_WhenResetBegins_PersistsResetState() user, CredentialType.Password); - await store.AddAsync(original); + await store.AddAsync(original, ct: TestContext.Current.CancellationToken); var updated = original.BeginReset( "hashed-reset-token", @@ -300,12 +300,12 @@ public async Task UpdateAsync_WhenResetBegins_PersistsResetState() await store.UpdateAsync( updated, - original.SecurityVersion); + original.SecurityVersion, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -351,17 +351,17 @@ public async Task DeleteAsync_WhenStateExists_RemovesState() var state = AuthenticationSecurityState.CreateAccount(Tenant, user); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( user, state.Scope, - state.CredentialType); + state.CredentialType, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( user, state.Scope, - state.CredentialType); + state.CredentialType, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -383,23 +383,23 @@ public async Task DeleteAsync_AccountState_DoesNotDeleteFactorState() user, CredentialType.Password); - await store.AddAsync(account); - await store.AddAsync(factor); + await store.AddAsync(account, ct: TestContext.Current.CancellationToken); + await store.AddAsync(factor, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var accountResult = await store.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var factorResult = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); accountResult.Should().BeNull(); factorResult.Should().NotBeNull(); @@ -430,23 +430,23 @@ public async Task DeleteAsync_RemovesOnlyExactLogicalState() var password = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password); var passkey = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey); - await store.AddAsync(password); - await store.AddAsync(passkey); + await store.AddAsync(password, ct: TestContext.Current.CancellationToken); + await store.AddAsync(passkey, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); var passwordResult = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); var passkeyResult = await store.GetAsync( user, AuthenticationSecurityScope.Factor, - CredentialType.Passkey); + CredentialType.Passkey, ct: TestContext.Current.CancellationToken); passwordResult.Should().BeNull(); passkeyResult.Should().NotBeNull(); @@ -473,17 +473,17 @@ public async Task GetAsync_IsTenantIsolated() tenantA, user); - await storeA.AddAsync(state); + await storeA.AddAsync(state, ct: TestContext.Current.CancellationToken); var fromA = await storeA.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromA!.Tenant.Should().Be(tenantA); @@ -512,23 +512,23 @@ public async Task DeleteAsync_IsTenantIsolated() tenantB, user); - await storeA.AddAsync(stateA); - await storeB.AddAsync(stateB); + await storeA.AddAsync(stateA, ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(stateB, ct: TestContext.Current.CancellationToken); await storeA.DeleteAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var fromA = await storeA.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); fromA.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs index 727c3797..4caa7741 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs @@ -25,7 +25,7 @@ public async Task AuthorizeAsync_WhenAccessOrchestratorAllows_ReturnsAllowed() .Returns, CancellationToken>( async (_, command, ct) => await command.ExecuteAsync(ct)); - var result = await f.Sut.AuthorizeAsync(context); + var result = await f.Sut.AuthorizeAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.RequiresReauthentication.Should().BeFalse(); @@ -54,7 +54,7 @@ public async Task AuthorizeAsync_WhenAccessOrchestratorDenies_ReturnsDenied() It.IsAny())) .ThrowsAsync(exception); - var result = await f.Sut.AuthorizeAsync(context); + var result = await f.Sut.AuthorizeAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.RequiresReauthentication.Should().BeFalse(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs index f8c0caae..d0b698d8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs @@ -45,7 +45,7 @@ public async Task CreateAsync_WhenValid_CreatesAndPersistsRole() var result = await f.Sut.CreateAsync( context, " Administrators ", - permissions); + permissions, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(captured); @@ -144,7 +144,7 @@ public async Task RenameAsync_WhenValid_RenamesAndSavesWithOriginalVersion() await f.Sut.RenameAsync( context, role.Id, - " New Name "); + " New Name ", ct: TestContext.Current.CancellationToken); role.Name.Should().Be("New Name"); role.NormalizedName.Should().Be("NEW NAME"); @@ -224,7 +224,7 @@ public async Task DeleteAsync_WhenValid_RemovesAssignmentsAndUsesRequestedMode( var result = await f.Sut.DeleteAsync( context, role.Id, - mode); + mode, ct: TestContext.Current.CancellationToken); result.RoleId.Should().Be(role.Id); result.RemovedAssignments.Should().Be(13); @@ -322,7 +322,7 @@ public async Task SetPermissionsAsync_WhenValid_UpdatesAndSavesWithOriginalVersi await f.Sut.SetPermissionsAsync( context, role.Id, - permissions); + permissions, ct: TestContext.Current.CancellationToken); role.Permissions.Should().ContainSingle().Which.Should().Be(Permission.From("users.read")); @@ -376,7 +376,7 @@ public async Task QueryAsync_ForwardsQueryAndReturnsStoreResult() It.IsAny())) .ReturnsAsync(expected); - var result = await f.Sut.QueryAsync(context, query); + var result = await f.Sut.QueryAsync(context, query, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs index b76b6146..484ee8ed 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs @@ -27,10 +27,10 @@ public async Task AddAsync_WhenValid_PersistsRole() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(role.Id); @@ -49,7 +49,7 @@ public async Task AddAsync_WhenActiveNormalizedNameAlreadyExists_ThrowsConflict( var first = CreateRole("Administrators"); var duplicate = CreateRole(" administrators "); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); var act = () => store.AddAsync(duplicate); @@ -65,13 +65,13 @@ public async Task AddAsync_WhenDeletedRoleHasSameNormalizedName_ThrowsConflict() var first = CreateRole("Administrators"); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, first.Id), first.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var replacement = CreateRole("Administrators"); @@ -89,13 +89,13 @@ public async Task AddAsync_WhenPreviousRoleWasHardDeleted_AllowsNameReuse() var first = CreateRole("Administrators"); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, first.Id), first.Version, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); var replacement = CreateRole("Administrators"); @@ -104,7 +104,7 @@ await store.DeleteAsync( await act.Should().NotThrowAsync(); var result = await store.GetByNameAsync( - replacement.NormalizedName); + replacement.NormalizedName, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(replacement.Id); @@ -122,10 +122,10 @@ public async Task GetByNameAsync_WhenRoleExists_ReturnsRole() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var result = await store.GetByNameAsync( - role.NormalizedName); + role.NormalizedName, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(role.Id); @@ -140,16 +140,16 @@ public async Task GetByNameAsync_WhenRoleIsDeleted_ReturnsNull() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, role.Id), role.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var result = await store.GetByNameAsync( - role.NormalizedName); + role.NormalizedName, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -168,12 +168,12 @@ public async Task GetByIdsAsync_ReturnsOnlyRequestedRoles() var operatorRole = CreateRole("Operators"); var unrelated = CreateRole("Auditors"); - await store.AddAsync(admin); - await store.AddAsync(operatorRole); - await store.AddAsync(unrelated); + await store.AddAsync(admin, ct: TestContext.Current.CancellationToken); + await store.AddAsync(operatorRole, ct: TestContext.Current.CancellationToken); + await store.AddAsync(unrelated, ct: TestContext.Current.CancellationToken); var result = await store.GetByIdsAsync( - [admin.Id, operatorRole.Id]); + [admin.Id, operatorRole.Id], ct: TestContext.Current.CancellationToken); result.Select(x => x.Id) .Should() @@ -192,17 +192,17 @@ public async Task GetByIdsAsync_DoesNotReturnDeletedRoles() var active = CreateRole("Active"); var deleted = CreateRole("Deleted"); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, deleted.Id), deleted.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var result = await store.GetByIdsAsync( - [active.Id, deleted.Id]); + [active.Id, deleted.Id], ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().Id.Should().Be(active.Id); @@ -220,10 +220,10 @@ public async Task SaveAsync_WhenRoleIsChanged_PersistsChanges() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -233,10 +233,10 @@ public async Task SaveAsync_WhenRoleIsChanged_PersistsChanges() await store.SaveAsync( persisted, - expectedVersion); + expectedVersion, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Name.Should().Be("Operators"); @@ -251,10 +251,10 @@ public async Task SaveAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -281,11 +281,11 @@ public async Task SaveAsync_WhenRenamedToExistingActiveRole_ThrowsConflict() var admin = CreateRole("Administrators"); var operators = CreateRole("Operators"); - await store.AddAsync(admin); - await store.AddAsync(operators); + await store.AddAsync(admin, ct: TestContext.Current.CancellationToken); + await store.AddAsync(operators, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new RoleKey(Tenant, operators.Id)); + new RoleKey(Tenant, operators.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -315,7 +315,7 @@ public async Task DeleteAsync_WhenSoftDeleted_KeepsRoleButMarksDeleted() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var deletedAt = Now.AddMinutes(1); @@ -323,10 +323,10 @@ await store.DeleteAsync( new RoleKey(Tenant, role.Id), role.Version, DeleteMode.Soft, - deletedAt); + deletedAt, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsDeleted.Should().BeTrue(); @@ -341,21 +341,21 @@ public async Task DeleteAsync_WhenHardDeleted_RemovesRole() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, role.Id), role.Version, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); var exists = await store.ExistsAsync( - new RoleKey(Tenant, role.Id)); + new RoleKey(Tenant, role.Id), ct: TestContext.Current.CancellationToken); exists.Should().BeFalse(); } @@ -368,7 +368,7 @@ public async Task DeleteAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() var role = CreateRole("Administrators"); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); var act = () => store.DeleteAsync( new RoleKey(Tenant, role.Id), @@ -393,20 +393,20 @@ public async Task QueryAsync_WhenIncludeDeletedIsFalse_ExcludesDeletedRoles() var active = CreateRole("Active"); var deleted = CreateRole("Deleted"); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, deleted.Id), deleted.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new RoleQuery { IncludeDeleted = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should() .Contain(x => x.Id == active.Id); @@ -424,20 +424,20 @@ public async Task QueryAsync_WhenIncludeDeletedIsTrue_IncludesDeletedRoles() var active = CreateRole("Active"); var deleted = CreateRole("Deleted"); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new RoleKey(Tenant, deleted.Id), deleted.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new RoleQuery { IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.Id) .Should() @@ -453,14 +453,14 @@ public async Task QueryAsync_WhenSearchSpecified_SearchesNormalizedName() var admin = CreateRole("Global Administrators"); var operators = CreateRole("Operators"); - await store.AddAsync(admin); - await store.AddAsync(operators); + await store.AddAsync(admin, ct: TestContext.Current.CancellationToken); + await store.AddAsync(operators, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new RoleQuery { Search = " admin " - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().Id.Should().Be(admin.Id); @@ -481,7 +481,7 @@ public async Task QueryAsync_SortsBeforeApplyingPagination() "Delta" }) { - await store.AddAsync(CreateRole(name)); + await store.AddAsync(CreateRole(name), ct: TestContext.Current.CancellationToken); } var result = await store.QueryAsync( @@ -491,7 +491,7 @@ public async Task QueryAsync_SortsBeforeApplyingPagination() PageSize = 2, SortBy = nameof(Role.Name), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(5); result.PageNumber.Should().Be(2); @@ -522,13 +522,13 @@ public async Task Store_IsTenantIsolated() "Administrators", tenantA); - await storeA.AddAsync(roleA); + await storeA.AddAsync(roleA, ct: TestContext.Current.CancellationToken); var fromA = await storeA.GetAsync( - new RoleKey(tenantA, roleA.Id)); + new RoleKey(tenantA, roleA.Id), ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetAsync( - new RoleKey(tenantB, roleA.Id)); + new RoleKey(tenantB, roleA.Id), ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); @@ -537,7 +537,7 @@ public async Task Store_IsTenantIsolated() new RoleQuery { IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); queryB.Items.Should() .NotContain(x => x.Id == roleA.Id); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs index 1723e3bf..700b5327 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs @@ -30,9 +30,9 @@ public async Task AssignAsync_WhenValid_PersistsAssignment() var roleId = RoleId.New(); var assignedAt = Now.AddMinutes(-10); - await store.AssignAsync(user, roleId, assignedAt); + await store.AssignAsync(user, roleId, assignedAt, ct: TestContext.Current.CancellationToken); - var result = await store.GetAssignmentsAsync(user); + var result = await store.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); @@ -53,7 +53,7 @@ public async Task AssignAsync_WhenSameRoleAlreadyAssigned_ThrowsConflict() var user = UserKey.New(); var roleId = RoleId.New(); - await store.AssignAsync(user, roleId, Now); + await store.AssignAsync(user, roleId, Now, ct: TestContext.Current.CancellationToken); var act = () => store.AssignAsync( user, @@ -75,10 +75,10 @@ public async Task AssignAsync_SameUserCanHaveMultipleRoles() var roleA = RoleId.New(); var roleB = RoleId.New(); - await store.AssignAsync(user, roleA, Now); - await store.AssignAsync(user, roleB, Now.AddMinutes(1)); + await store.AssignAsync(user, roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(user, roleB, Now.AddMinutes(1), ct: TestContext.Current.CancellationToken); - var result = await store.GetAssignmentsAsync(user); + var result = await store.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); @@ -97,11 +97,11 @@ public async Task AssignAsync_SameRoleCanBeAssignedToMultipleUsers() var userB = UserKey.New(); var roleId = RoleId.New(); - await store.AssignAsync(userA, roleId, Now); - await store.AssignAsync(userB, roleId, Now.AddMinutes(1)); + await store.AssignAsync(userA, roleId, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(userB, roleId, Now.AddMinutes(1), ct: TestContext.Current.CancellationToken); - var assignmentsA = await store.GetAssignmentsAsync(userA); - var assignmentsB = await store.GetAssignmentsAsync(userB); + var assignmentsA = await store.GetAssignmentsAsync(userA, ct: TestContext.Current.CancellationToken); + var assignmentsB = await store.GetAssignmentsAsync(userB, ct: TestContext.Current.CancellationToken); assignmentsA.Should().ContainSingle(); assignmentsB.Should().ContainSingle(); @@ -126,10 +126,10 @@ public async Task GetAssignmentsAsync_ReturnsOnlyRequestedUserAssignments() var roleA = RoleId.New(); var roleB = RoleId.New(); - await store.AssignAsync(userA, roleA, Now); - await store.AssignAsync(userB, roleB, Now); + await store.AssignAsync(userA, roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(userB, roleB, Now, ct: TestContext.Current.CancellationToken); - var result = await store.GetAssignmentsAsync(userA); + var result = await store.GetAssignmentsAsync(userA, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().UserKey.Should().Be(userA); @@ -142,7 +142,7 @@ public async Task GetAssignmentsAsync_WhenUserHasNoAssignments_ReturnsEmpty() await using var db = await CreateDatabaseAsync(); var store = db.CreateStore(Tenant); - var result = await store.GetAssignmentsAsync(UserKey.New()); + var result = await store.GetAssignmentsAsync(UserKey.New(), ct: TestContext.Current.CancellationToken); result.Should().BeEmpty(); } @@ -162,12 +162,12 @@ public async Task RemoveAsync_WhenAssignmentExists_RemovesOnlyRequestedAssignmen var roleA = RoleId.New(); var roleB = RoleId.New(); - await store.AssignAsync(user, roleA, Now); - await store.AssignAsync(user, roleB, Now); + await store.AssignAsync(user, roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(user, roleB, Now, ct: TestContext.Current.CancellationToken); - await store.RemoveAsync(user, roleA); + await store.RemoveAsync(user, roleA, ct: TestContext.Current.CancellationToken); - var result = await store.GetAssignmentsAsync(user); + var result = await store.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().RoleId.Should().Be(roleB); @@ -186,7 +186,7 @@ public async Task RemoveAsync_WhenAssignmentDoesNotExist_IsIdempotent() await act.Should().NotThrowAsync(); - var result = await store.GetAssignmentsAsync(user); + var result = await store.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); result.Should().BeEmpty(); } @@ -201,13 +201,13 @@ public async Task RemoveAsync_DoesNotRemoveSameRoleFromOtherUser() var userB = UserKey.New(); var roleId = RoleId.New(); - await store.AssignAsync(userA, roleId, Now); - await store.AssignAsync(userB, roleId, Now); + await store.AssignAsync(userA, roleId, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(userB, roleId, Now, ct: TestContext.Current.CancellationToken); - await store.RemoveAsync(userA, roleId); + await store.RemoveAsync(userA, roleId, ct: TestContext.Current.CancellationToken); - var assignmentsA = await store.GetAssignmentsAsync(userA); - var assignmentsB = await store.GetAssignmentsAsync(userB); + var assignmentsA = await store.GetAssignmentsAsync(userA, ct: TestContext.Current.CancellationToken); + var assignmentsB = await store.GetAssignmentsAsync(userB, ct: TestContext.Current.CancellationToken); assignmentsA.Should().BeEmpty(); @@ -231,14 +231,14 @@ public async Task RemoveAssignmentsByRoleAsync_RemovesRoleFromAllUsers() var targetRole = RoleId.New(); var otherRole = RoleId.New(); - await store.AssignAsync(userA, targetRole, Now); - await store.AssignAsync(userA, otherRole, Now); - await store.AssignAsync(userB, targetRole, Now); + await store.AssignAsync(userA, targetRole, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(userA, otherRole, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(userB, targetRole, Now, ct: TestContext.Current.CancellationToken); - await store.RemoveAssignmentsByRoleAsync(targetRole); + await store.RemoveAssignmentsByRoleAsync(targetRole, ct: TestContext.Current.CancellationToken); - var assignmentsA = await store.GetAssignmentsAsync(userA); - var assignmentsB = await store.GetAssignmentsAsync(userB); + var assignmentsA = await store.GetAssignmentsAsync(userA, ct: TestContext.Current.CancellationToken); + var assignmentsB = await store.GetAssignmentsAsync(userB, ct: TestContext.Current.CancellationToken); assignmentsA.Should().ContainSingle(); assignmentsA.Single().RoleId.Should().Be(otherRole); @@ -271,12 +271,12 @@ public async Task CountAssignmentsAsync_ReturnsNumberOfAssignmentsForRole() var roleA = RoleId.New(); var roleB = RoleId.New(); - await store.AssignAsync(UserKey.New(), roleA, Now); - await store.AssignAsync(UserKey.New(), roleA, Now); - await store.AssignAsync(UserKey.New(), roleA, Now); - await store.AssignAsync(UserKey.New(), roleB, Now); + await store.AssignAsync(UserKey.New(), roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(UserKey.New(), roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(UserKey.New(), roleA, Now, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(UserKey.New(), roleB, Now, ct: TestContext.Current.CancellationToken); - var result = await store.CountAssignmentsAsync(roleA); + var result = await store.CountAssignmentsAsync(roleA, ct: TestContext.Current.CancellationToken); result.Should().Be(3); } @@ -288,7 +288,7 @@ public async Task CountAssignmentsAsync_WhenNoAssignmentsExist_ReturnsZero() var store = db.CreateStore(Tenant); var result = - await store.CountAssignmentsAsync(RoleId.New()); + await store.CountAssignmentsAsync(RoleId.New(), ct: TestContext.Current.CancellationToken); result.Should().Be(0); } @@ -311,10 +311,10 @@ public async Task GetAssignmentsAsync_IsTenantIsolated() var user = UserKey.New(); var roleId = RoleId.New(); - await storeA.AssignAsync(user, roleId, Now); + await storeA.AssignAsync(user, roleId, Now, ct: TestContext.Current.CancellationToken); - var fromA = await storeA.GetAssignmentsAsync(user); - var fromB = await storeB.GetAssignmentsAsync(user); + var fromA = await storeA.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); + var fromB = await storeB.GetAssignmentsAsync(user, ct: TestContext.Current.CancellationToken); fromA.Should().ContainSingle(); fromB.Should().BeEmpty(); @@ -333,13 +333,13 @@ public async Task CountAssignmentsAsync_IsTenantIsolated() var roleId = RoleId.New(); - await storeA.AssignAsync(UserKey.New(), roleId, Now); - await storeA.AssignAsync(UserKey.New(), roleId, Now); + await storeA.AssignAsync(UserKey.New(), roleId, Now, ct: TestContext.Current.CancellationToken); + await storeA.AssignAsync(UserKey.New(), roleId, Now, ct: TestContext.Current.CancellationToken); - await storeB.AssignAsync(UserKey.New(), roleId, Now); + await storeB.AssignAsync(UserKey.New(), roleId, Now, ct: TestContext.Current.CancellationToken); - var countA = await storeA.CountAssignmentsAsync(roleId); - var countB = await storeB.CountAssignmentsAsync(roleId); + var countA = await storeA.CountAssignmentsAsync(roleId, ct: TestContext.Current.CancellationToken); + var countB = await storeB.CountAssignmentsAsync(roleId, ct: TestContext.Current.CancellationToken); countA.Should().Be(2); countB.Should().Be(1); @@ -361,16 +361,16 @@ public async Task RemoveAssignmentsByRoleAsync_IsTenantIsolated() var userA = UserKey.New(); var userB = UserKey.New(); - await storeA.AssignAsync(userA, roleId, Now); - await storeB.AssignAsync(userB, roleId, Now); + await storeA.AssignAsync(userA, roleId, Now, ct: TestContext.Current.CancellationToken); + await storeB.AssignAsync(userB, roleId, Now, ct: TestContext.Current.CancellationToken); - await storeA.RemoveAssignmentsByRoleAsync(roleId); + await storeA.RemoveAssignmentsByRoleAsync(roleId, ct: TestContext.Current.CancellationToken); var assignmentsA = - await storeA.GetAssignmentsAsync(userA); + await storeA.GetAssignmentsAsync(userA, ct: TestContext.Current.CancellationToken); var assignmentsB = - await storeB.GetAssignmentsAsync(userB); + await storeB.GetAssignmentsAsync(userB, ct: TestContext.Current.CancellationToken); assignmentsA.Should().BeEmpty(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserPermissionServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserPermissionServiceTests.cs index 39fd8b08..051c3e84 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserPermissionServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserPermissionServiceTests.cs @@ -37,7 +37,7 @@ public async Task GetMyPermissionsAsync_ReturnsDistinctSortedPermissions() Permission.From("orders.update") ]); - var result = await f.Sut.GetMyPermissionsAsync(f.Context); + var result = await f.Sut.GetMyPermissionsAsync(f.Context, ct: TestContext.Current.CancellationToken); result.Permissions.Select(x => x.Value) .Should().Equal( @@ -65,7 +65,7 @@ public async Task GetMyPermissionsAsync_WhenNoPermissions_ReturnsEmpty() It.IsAny())) .ReturnsAsync(Array.Empty()); - var result = await f.Sut.GetMyPermissionsAsync(f.Context); + var result = await f.Sut.GetMyPermissionsAsync(f.Context, ct: TestContext.Current.CancellationToken); result.Permissions.Should().BeEmpty(); } @@ -82,7 +82,7 @@ public async Task GetMyPermissionsAsync_UsesResourceTenantAndActor() It.IsAny())) .ReturnsAsync(Array.Empty()); - await f.Sut.GetMyPermissionsAsync(f.Context); + await f.Sut.GetMyPermissionsAsync(f.Context, ct: TestContext.Current.CancellationToken); f.Permissions.Verify(x => x.GetPermissionsAsync( f.Context.ResourceTenant, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs index b1ea60c5..dbc71e6a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs @@ -49,7 +49,7 @@ public async Task AssignAsync_WhenRoleExists_NormalizesRoleNameAndAssignsTargetU await f.Sut.AssignAsync( context, target, - " administrators "); + " administrators ", ct: TestContext.Current.CancellationToken); f.RoleStore.Verify(x => x.GetByNameAsync( "ADMINISTRATORS", @@ -156,7 +156,7 @@ public async Task AssignAsync_UsesResourceTenantForBothStores() await f.Sut.AssignAsync( context, target, - "Admin"); + "Admin", ct: TestContext.Current.CancellationToken); f.RoleFactory.Verify( x => x.Create(context.ResourceTenant), @@ -196,7 +196,7 @@ public async Task RemoveAsync_WhenRoleExists_NormalizesRoleNameAndRemovesAssignm await f.Sut.RemoveAsync( context, target, - " operators "); + " operators ", ct: TestContext.Current.CancellationToken); f.UserRoleStore.Verify(x => x.RemoveAsync( target, @@ -259,7 +259,7 @@ public async Task RemoveAsync_WhenRoleIsDeleted_StillRemovesAssignment() await f.Sut.RemoveAsync( context, target, - "legacy"); + "legacy", ct: TestContext.Current.CancellationToken); f.UserRoleStore.Verify(x => x.RemoveAsync( target, @@ -309,7 +309,7 @@ public async Task GetRolesAsync_WhenAssignmentsExist_JoinsAssignmentsWithRoles() var result = await f.Sut.GetRolesAsync( context, target, - new PageRequest()); + new PageRequest(), ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(2); result.Items.Should().HaveCount(2); @@ -373,7 +373,7 @@ public async Task GetRolesAsync_WhenAssignmentReferencesMissingRole_IgnoresOrpha var result = await f.Sut.GetRolesAsync( context, target, - new PageRequest()); + new PageRequest(), ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Should().ContainSingle(); @@ -419,7 +419,7 @@ public async Task GetRolesAsync_AppliesPaginationAfterJoin() { PageNumber = 2, PageSize = 1 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(3); result.PageNumber.Should().Be(2); @@ -457,7 +457,7 @@ public async Task GetRolesAsync_NormalizesInvalidPagingValues() { PageNumber = -10, PageSize = 0 - }); + }, ct: TestContext.Current.CancellationToken); result.PageNumber.Should().Be(1); result.PageSize.Should().Be(250); @@ -491,7 +491,7 @@ public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize() { PageNumber = 1, PageSize = 5000 - }); + }, ct: TestContext.Current.CancellationToken); result.PageSize.Should().Be(1000); } @@ -524,7 +524,7 @@ public async Task GetRolesAsync_PreservesPagingMetadata() PageSize = 20, SortBy = "name", Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.PageNumber.Should().Be(3); result.PageSize.Should().Be(20); @@ -554,7 +554,7 @@ public async Task GetRolesAsync_UsesResourceTenantForBothStores() await f.Sut.GetRolesAsync( context, target, - new PageRequest()); + new PageRequest(), ct: TestContext.Current.CancellationToken); f.RoleFactory.Verify( x => x.Create(context.ResourceTenant), @@ -620,7 +620,7 @@ public async Task GetRolesAsync_WhenSortByNameAscending_SortsByName() { SortBy = nameof(UserRoleInfo.Name), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.Name) @@ -655,7 +655,7 @@ public async Task GetRolesAsync_WhenSortByNameDescending_SortsByNameDescending() { SortBy = nameof(UserRoleInfo.Name), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.Name) @@ -694,7 +694,7 @@ public async Task GetRolesAsync_WhenSortByAssignedAtAscending_SortsByAssignedAt( { SortBy = nameof(UserRoleInfo.AssignedAt), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.AssignedAt) @@ -733,7 +733,7 @@ public async Task GetRolesAsync_WhenSortByAssignedAtDescending_SortsByAssignedAt { SortBy = nameof(UserRoleInfo.AssignedAt), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.AssignedAt) @@ -764,7 +764,7 @@ public async Task GetRolesAsync_WhenSortByIsNotSpecified_DefaultsToNameAscending var result = await f.Sut.GetRolesAsync( context, target, - new PageRequest()); + new PageRequest(), ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.Name) @@ -816,7 +816,7 @@ public async Task GetRolesAsync_SortsBeforeApplyingPagination() PageNumber = 2, PageSize = 2, SortBy = nameof(UserRoleInfo.Name) - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(5); result.PageNumber.Should().Be(2); @@ -874,7 +874,7 @@ public async Task GetRolesAsync_WhenNamesAreEqual_UsesRoleIdAsDeterministicTieBr new PageRequest { SortBy = nameof(UserRoleInfo.Name) - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.RoleId) .Should() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index 973c98fc..fa7af2e6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -21,7 +21,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() var pprovider = new Mock(); var lprovider = new Mock(); - provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) + provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), TestContext.Current.CancellationToken)) .ReturnsAsync(new PrimaryUserIdentifiers { UserName = "admin" @@ -39,7 +39,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() DateTimeOffset.UtcNow ); - var snapshot = await factory.CreateAsync(validation); + var snapshot = await factory.CreateAsync(validation, ct: TestContext.Current.CancellationToken); snapshot.Should().NotBeNull(); snapshot!.Identity.PrimaryUserName.Should().Be("admin"); @@ -55,7 +55,7 @@ public async Task CreateAsync_should_return_null_when_invalid() var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Invalid(SessionState.NotFound); - var snapshot = await factory.CreateAsync(validation); + var snapshot = await factory.CreateAsync(validation, ct: TestContext.Current.CancellationToken); snapshot.Should().BeNull(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs index b2aeea08..e6148acd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs @@ -24,21 +24,24 @@ public UAuthRequestClientTests() .Returns(Task.CompletedTask); } - private UAuthRequestClient CreateSut(UAuthClientProfile profile = UAuthClientProfile.BlazorWasm) + private UAuthRequestClient CreateSut(UAuthClientProfile profile = UAuthClientProfile.BlazorWasm, string? basePath = null) { - var options = Options.Create(new UAuthClientOptions + var options = new UAuthClientOptions { ClientProfile = profile - }); + }; + + if (basePath is not null) + options.Endpoints.BasePath = basePath; - return new UAuthRequestClient(JSInterop.JSRuntime, _bootstrapper.Object, options); + return new UAuthRequestClient(JSInterop.JSRuntime, _bootstrapper.Object, Options.Create(options)); } [Fact] public async Task NavigateAsync_InvokesUAuthPost() { var sut = CreateSut(); - await sut.NavigateAsync("/login"); + await sut.NavigateAsync("/login", ct: Xunit.TestContext.Current.CancellationToken); var invocation = JSInterop.Invocations.Single(x => x.Identifier == "uauth.post"); invocation.Arguments.Should().ContainSingle(); @@ -58,7 +61,7 @@ public async Task SendFormAsync_ReturnsTransportResult() .SetResult(expected); var sut = CreateSut(); - var result = await sut.SendFormAsync("/login"); + var result = await sut.SendFormAsync("/login", ct: Xunit.TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); } @@ -144,6 +147,70 @@ public async Task SendFormAsync_WhenBootstrapperFails_DoesNotInvokeJavascript() JSInterop.Invocations.Should().BeEmpty(); } + [Fact] + public async Task TryAndCommitThrottleKeepsStatusAndRetryGuidance() + { + JSInterop.Setup("uauth.tryAndCommit", _ => true) + .SetResult(System.Text.Json.JsonSerializer.SerializeToElement(new { status = 429, retryAfter = "45", isSuccess = false })); + var error = await Assert.ThrowsAsync(() => + CreateSut().TryAndCommitAsync("/try", "/commit", new { }, ct: Xunit.TestContext.Current.CancellationToken)); + Assert.Equal(System.Net.HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Equal("rate_limited", error.Code); + Assert.Equal(TimeSpan.FromSeconds(45), error.RetryAfter); + } + + [Fact] + public async Task TryAndCommitStillDeserializesSuccessfulPreview() + { + JSInterop.Setup("uauth.tryAndCommit", _ => true) + .SetResult(System.Text.Json.JsonSerializer.SerializeToElement(new { isSuccess = true, previewReceipt = "receipt" })); + var result = await CreateSut().TryAndCommitAsync("/try", "/commit", new { }, ct: Xunit.TestContext.Current.CancellationToken); + Assert.True(result.IsSuccess); + Assert.Equal("receipt", result.PreviewReceipt); + } + + [Fact] + public async Task ObservedLogoutReturnsHttpRejectionRatherThanSubmittingAnUnobservedForm() + { + JSInterop.Setup("uauth.post", _ => true) + .SetResult(new UAuthTransportResult { Status = 429, RetryAfter = "15" }); + var result = await CreateSut().SendFormAsync("/auth/logout", null, navigateOnSuccess: true, ct: Xunit.TestContext.Current.CancellationToken); + Assert.Equal(429, result.Status); + Assert.Equal("15", result.RetryAfter); + var invocation = JSInterop.Invocations.Single(x => x.Identifier == "uauth.post"); + Assert.Equal("logout", GetProperty(invocation.Arguments.Single()!, "mode")); + } + + [Theory] + [InlineData("/auth", null)] + [InlineData("auth", null)] + [InlineData("https://example.com/auth", "https://example.com")] + [InlineData("http://localhost:5000/auth", "http://localhost:5000")] + public async Task SendFormAsync_ResolvesTrustedOriginCorrectly(string basePath, string? expectedOrigin) + { + // Arrange + JSInterop + .Setup("uauth.post", _ => true) + .SetResult(CreateTransportResult(200)); + + var sut = CreateSut(basePath: basePath); + + // Act + await sut.SendFormAsync("/auth/validate", ct: Xunit.TestContext.Current.CancellationToken); + + // Assert + var invocation = JSInterop.Invocations + .Single(x => x.Identifier == "uauth.post"); + + var options = invocation.Arguments.Single()!; + + GetProperty(options, "url") + .Should().Be("/auth/validate"); + + GetProperty(options, "trustedOrigin") + .Should().Be(expectedOrigin); + } + private sealed class TestTryResult { } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs new file mode 100644 index 00000000..527a1367 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Text.Json; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class ClientRateLimitingTests : UAuthClientTestBase +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("invalid", null)] + [InlineData("-1", null)] + [InlineData("1.5", null)] + [InlineData("60", 60)] + [InlineData("0", 0)] + [InlineData(" 120 ", 120)] + [InlineData("9999999999999999999999999", null)] + public void RetryAfterHandlesMissingInvalidAndDeltaSeconds(string? header, int? expected) + { + Assert.Equal(expected is null ? null : TimeSpan.FromSeconds(expected.Value), UAuthRateLimit.ParseRetryAfter(header, DateTimeOffset.UtcNow)); + } + + [Fact] + public void RetryAfterParsesHttpDateAndPastDate() + { + var now = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.Zero); + Assert.Equal(TimeSpan.FromMinutes(2), UAuthRateLimit.ParseRetryAfter(now.AddMinutes(2).ToString("r"), now)); + Assert.Equal(TimeSpan.Zero, UAuthRateLimit.ParseRetryAfter(now.AddMinutes(-1).ToString("r"), now)); + } + + [Theory] + [InlineData(null)] + [InlineData("90")] + public async Task ValidationThrottlePreservesStateAndBlazorPrincipal_ThenRevocationStillClears(string? retry) + { + var clock = new TestClock(); + var snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { UserKey = UserKey.FromString("rate-user"), Tenant = TenantKey.Single }, + Claims = ClaimsSnapshot.Empty + }; + Request.SetupSequence(x => x.SendFormAsync("/auth/validate", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true, Body = JsonSerializer.SerializeToElement(new AuthValidationResult { State = SessionState.Active, Snapshot = snapshot }) }) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = retry }) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true, Body = JsonSerializer.SerializeToElement(new AuthValidationResult { State = SessionState.Revoked }) }); + using var manager = new UAuthStateManager(CreateClient(flows: CreateFlowClient()), Events.Object, clock); + var provider = new UAuthAuthenticationStateProvider(manager); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); + var lastValidated = manager.State.LastValidatedAt; + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + Assert.True(manager.State.IsAuthenticated); + Assert.True((await provider.GetAuthenticationStateAsync()).User.Identity!.IsAuthenticated); + Assert.Equal(lastValidated, manager.State.LastValidatedAt); + Request.Verify(x => x.SendFormAsync("/auth/validate", null, It.IsAny()), Times.Exactly(2)); + clock.Advance(TimeSpan.FromMinutes(2)); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + Assert.False(manager.State.IsAuthenticated); + Assert.False((await provider.GetAuthenticationStateAsync()).User.Identity!.IsAuthenticated); + } + + [Theory] + [InlineData(null)] + [InlineData("60")] + public async Task RefreshIsThrottledWithoutReauthOrImmediateRepeatedRequest(string? retry) + { + Request.Setup(x => x.SendFormAsync("/auth/refresh", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = retry, RefreshOutcome = "reauth-required" }); + var flow = CreateFlowClient(); + var result = await flow.RefreshAsync(); + Assert.False(result.IsSuccess); + Assert.Equal(429, result.Status); + Assert.Equal("rate_limited", result.ErrorCode); + Assert.Equal(RefreshOutcome.None, result.Outcome); + Assert.Equal(retry is null ? null : TimeSpan.FromSeconds(60), result.RetryAfter); + await flow.RefreshAsync(isAuto: true); + Request.Verify(x => x.SendFormAsync("/auth/refresh", null, It.IsAny()), Times.Once); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GenuineRefresh401StillRequiresReauth() + { + Request.Setup(x => x.SendFormAsync("/auth/refresh", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 401 }); + var result = await CreateFlowClient().RefreshAsync(); + Assert.Equal(401, result.Status); + Assert.Equal(RefreshOutcome.ReauthRequired, result.Outcome); + } + + [Theory] + [InlineData(UAuthSubmitMode.TryOnly)] + [InlineData(UAuthSubmitMode.TryAndCommit)] + public async Task LoginThrottleHasDistinctResultAndNeverCommits(UAuthSubmitMode mode) + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = "60" }); + Request.Setup(x => x.TryAndCommitAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(UAuthRateLimit.Exception("60")); + IUAuthTryResult result = await CreateFlowClient().TryLoginAsync(new LoginRequest(), mode); + Assert.False(result.IsSuccess); + Assert.Null(result.Reason); + Assert.Equal(429, result.Status); + Assert.Equal("rate_limited", result.ErrorCode); + Assert.Equal(TimeSpan.FromMinutes(1), result.RetryAfter); + Request.Verify(x => x.NavigateAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LogoutThrottleIsNotSuccessAndDoesNotPublishLogout() + { + Request.Setup(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429 }); + var error = await Assert.ThrowsAsync(() => CreateFlowClient().LogoutAsync()); + Assert.Equal(HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Equal("rate_limited", error.Code); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task AuthorizationAndRecoveryThrottleAreResults_NotForbiddenOrRetry() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = "30" }); + var result = await CreateClient().Authorization.CheckAsync(new CodeBeam.UltimateAuth.Authorization.Contracts.AuthorizationCheckRequest { Action = "read", Resource = "example" }); + Assert.Equal(429, result.Status); + Assert.True(result.Http.IsTooManyRequests); + Assert.False(result.Http.IsForbidden); + Assert.Equal("rate_limited", result.Problem!.Detail); + Assert.Equal(TimeSpan.FromSeconds(30), result.Problem.RetryAfter); + Assert.Null(result.Value); + var recovery = await CreateClient().Credentials.CompleteResetAsync(new CodeBeam.UltimateAuth.Credentials.Contracts.CompleteResetCredentialRequest + { Identifier = "user", CredentialType = CredentialType.Password, ResetToken = "proof", NewSecret = "changed-password" }); + Assert.Equal(429, recovery.Status); + Assert.Equal("rate_limited", recovery.Problem!.Detail); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + Request.Verify(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Theory] + [InlineData(null)] + [InlineData(120)] + public async Task AutomaticRefreshDefersThrottle_ThenHonorsReal401(int? seconds) + { + var clock = new TestClock(); + var flows = new Mock(); + flows.SetupSequence(x => x.RefreshAsync(true)) + .ReturnsAsync(new RefreshResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = seconds is null ? null : TimeSpan.FromSeconds(seconds.Value) }) + .ReturnsAsync(new RefreshResult { Status = 401, Outcome = RefreshOutcome.ReauthRequired }); + var diagnostics = new UAuthClientDiagnostics(); + await using var coordinator = new SessionCoordinator(CreateClient(flows: flows.Object), Mock.Of(), + Options.Create(new UAuthClientOptions { Reauth = new() { Behavior = ReauthBehavior.RaiseEvent } }), diagnostics, clock); + await coordinator.TickAsync(); + await coordinator.TickAsync(); + Assert.False(diagnostics.IsTerminated); + flows.Verify(x => x.RefreshAsync(true), Times.Once); + clock.Advance(TimeSpan.FromMinutes(3)); + await coordinator.TickAsync(); + Assert.True(diagnostics.IsTerminated); + flows.Verify(x => x.RefreshAsync(true), Times.Exactly(2)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs index c3338855..d4924138 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -30,7 +30,7 @@ public async Task StartAsync_should_not_start_when_auto_refresh_disabled() }); var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); - await coordinator.StartAsync(); + await coordinator.StartAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.False(diagnostics.IsRunning); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs index c16255aa..68151e9e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs @@ -1,7 +1,9 @@ -ο»Ώusing CodeBeam.UltimateAuth.Client; +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Extensions.Options; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -11,38 +13,204 @@ 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 deviceId = TestDevice.Default().DeviceId!.Value.Value; + var provider = CreateDeviceProvider(deviceId); var browser = new Mock(); - var bootstrapper = new UAuthClientBootstrapper(provider.Object, browser.Object); + var bootstrapper = CreateBootstrapper( + provider, + browser, + new UAuthClientOptions()); + + await bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken); + await bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken); + + bootstrapper.IsStarted.Should().BeTrue(); - await bootstrapper.EnsureStartedAsync(); - await bootstrapper.EnsureStartedAsync(); + provider.Verify( + x => x.GetOrCreateAsync(default), + Times.Once); - provider.Verify(x => x.GetOrCreateAsync(default), Times.Once); - browser.Verify(x => x.SetDeviceIdAsync(deviceId.Value), Times.Once); + browser.Verify( + x => x.SetDeviceIdAsync(deviceId), + Times.Once); + + browser.Verify( + x => x.ConfigureAntiforgeryAsync( + "/auth/antiforgery/token", + null), + 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 deviceId = TestDevice.Default().DeviceId!.Value.Value; + var provider = CreateDeviceProvider(deviceId); var browser = new Mock(); - var bootstrapper = new UAuthClientBootstrapper(provider.Object, browser.Object); - var tasks = Enumerable.Range(0, 10).Select(_ => bootstrapper.EnsureStartedAsync()); + var bootstrapper = CreateBootstrapper( + provider, + browser, + new UAuthClientOptions()); + + 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); + bootstrapper.IsStarted.Should().BeTrue(); + + provider.Verify( + x => x.GetOrCreateAsync(default), + Times.Once); + + browser.Verify( + x => x.SetDeviceIdAsync(deviceId), + Times.Once); + + browser.Verify( + x => x.ConfigureAntiforgeryAsync( + "/auth/antiforgery/token", + null), + Times.Once); + } + + [Fact] + public async Task EnsureStartedAsync_should_configure_same_origin_antiforgery() + { + var deviceId = TestDevice.Default().DeviceId!.Value.Value; + + var provider = CreateDeviceProvider(deviceId); + var browser = new Mock(); + + var options = new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/identity", + AntiforgeryToken = "/antiforgery/token" + } + }; + + var bootstrapper = CreateBootstrapper( + provider, + browser, + options); + + await bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken); + + browser.Verify( + x => x.ConfigureAntiforgeryAsync( + "/identity/antiforgery/token", + null), + Times.Once); + } + + [Fact] + public async Task EnsureStartedAsync_should_configure_cross_origin_antiforgery() + { + var deviceId = TestDevice.Default().DeviceId!.Value.Value; + + var provider = CreateDeviceProvider(deviceId); + var browser = new Mock(); + + var options = new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "https://localhost:6110/auth", + AntiforgeryToken = "/antiforgery/token" + } + }; + + var bootstrapper = CreateBootstrapper( + provider, + browser, + options); + + await bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken); + + browser.Verify( + x => x.ConfigureAntiforgeryAsync( + "https://localhost:6110/auth/antiforgery/token", + "https://localhost:6110"), + Times.Once); + } + + [Fact] + public async Task EnsureStartedAsync_should_retry_when_antiforgery_configuration_fails() + { + var deviceId = TestDevice.Default().DeviceId!.Value.Value; + + var provider = CreateDeviceProvider(deviceId); + var browser = new Mock(); + + var attempts = 0; + + browser + .Setup(x => x.ConfigureAntiforgeryAsync( + It.IsAny(), + It.IsAny())) + .Returns(() => + { + attempts++; + + if (attempts == 1) + { + return new ValueTask( + Task.FromException( + new InvalidOperationException( + "JS initialization failed."))); + } + + return ValueTask.CompletedTask; + }); + + var bootstrapper = CreateBootstrapper( + provider, + browser, + new UAuthClientOptions()); + + await Assert.ThrowsAsync( + () => bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken)); + + bootstrapper.IsStarted.Should().BeFalse(); + + await bootstrapper.EnsureStartedAsync(ct: TestContext.Current.CancellationToken); + + bootstrapper.IsStarted.Should().BeTrue(); + + browser.Verify( + x => x.ConfigureAntiforgeryAsync( + "/auth/antiforgery/token", + null), + Times.Exactly(2)); + } + + private static Mock CreateDeviceProvider( + string deviceId) + { + var provider = new Mock(); + + provider + .Setup(x => x.GetOrCreateAsync(default)) + .ReturnsAsync( + TestDevice.Default().DeviceId!.Value); + + return provider; + } + + private static UAuthClientBootstrapper CreateBootstrapper( + Mock provider, + Mock browser, + UAuthClientOptions options) + { + return new UAuthClientBootstrapper( + provider.Object, + browser.Object, + Options.Create(options)); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index ee8b992c..dbae2494 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -320,19 +320,49 @@ await client.LoginAsync(new LoginRequest } [Fact] - public async Task Logout_Should_Navigate_To_Logout() + public async Task Logout_Should_Observe_Server_Response() { var mock = new Mock(); + mock.Setup(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true }); + var client = CreateFlowClient(mock); + await client.LogoutAsync(); + mock.Verify(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny()), Times.Once); + } - mock.Setup(x => x.NavigateAsync( - It.IsAny(), + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Logout_Should_Respect_Navigate_Option_And_Publish_Event(bool navigate) + { + var request = new Mock(); + var events = new Mock(); + + request.Setup(x => x.SendFormAsync( + "/auth/logout", + null, + navigate, + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Ok = true + }); + + var client = CreateFlowClient(request, events); + + await client.LogoutAsync(navigate: navigate); + + request.Verify(x => x.SendFormAsync( + "/auth/logout", null, - It.IsAny())) - .Returns(Task.CompletedTask); + navigate, + It.IsAny()), Times.Once); - var client = CreateFlowClient(mock); - await client.LogoutAsync(); - mock.Verify(x => x.NavigateAsync("/auth/logout", null, It.IsAny()), Times.Once); + events.Verify(x => x.PublishAsync( + It.Is(e => + e.Type == UAuthStateEvent.LogoutVariant)), + Times.Once); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceClientTests.cs index 781e1b8f..e9680496 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceClientTests.cs @@ -40,19 +40,19 @@ public async Task Resource_Methods_Should_Forward_Correct_Method(string method) switch (method) { case "GET": - await client.GetAsync("/api/products"); + await client.GetAsync("/api/products", ct: TestContext.Current.CancellationToken); break; case "POST": - await client.PostAsync("/api/products", payload); + await client.PostAsync("/api/products", payload, ct: TestContext.Current.CancellationToken); break; case "PUT": - await client.PutAsync("/api/products", payload); + await client.PutAsync("/api/products", payload, ct: TestContext.Current.CancellationToken); break; case "PATCH": - await client.PatchAsync("/api/products", payload); + await client.PatchAsync("/api/products", payload, ct: TestContext.Current.CancellationToken); break; } @@ -82,7 +82,7 @@ public async Task Get_Should_Deserialize_Success_Response() }); var result = await CreateClient() - .GetAsync("/api/products/1"); + .GetAsync("/api/products/1", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Status.Should().Be(200); @@ -108,7 +108,7 @@ public async Task Get_Should_Return_Default_When_Body_Is_Empty() }); var result = await CreateClient() - .GetAsync("/api/products/1"); + .GetAsync("/api/products/1", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Status.Should().Be(204); @@ -138,7 +138,7 @@ public async Task Get_Should_Parse_Problem_On_Failure() }); var result = await CreateClient() - .GetAsync("/api/products"); + .GetAsync("/api/products", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.Status.Should().Be(403); @@ -164,7 +164,7 @@ public async Task Get_Should_Handle_Invalid_Problem_Json() }); var result = await CreateClient() - .GetAsync("/api/products"); + .GetAsync("/api/products", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.Status.Should().Be(500); @@ -187,7 +187,7 @@ public async Task Delete_Should_Return_Success_Without_Body() }); var result = await CreateClient() - .DeleteAsync("/api/products/1"); + .DeleteAsync("/api/products/1", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Status.Should().Be(204); @@ -211,7 +211,7 @@ public async Task Delete_Should_Parse_Problem_On_Failure() }); var result = await CreateClient() - .DeleteAsync("/api/products/1"); + .DeleteAsync("/api/products/1", ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.Status.Should().Be(404); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceRequestClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceRequestClientTests.cs index 06c5ede3..99c7c126 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceRequestClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResourceRequestClientTests.cs @@ -65,7 +65,7 @@ public async Task Send_Should_Ensure_Bootstrap_Before_JS_Request() var client = CreateClient(); - var task = client.SendAsync("GET", "/api/products"); + var task = client.SendAsync("GET", "/api/products", ct: TestContext.Current.CancellationToken); _bootstrapper.Verify(x => x.EnsureStartedAsync( It.IsAny()), Times.Once); @@ -111,7 +111,7 @@ public async Task Send_Should_Pass_Correct_Arguments_To_JS() await client.SendAsync( "POST", "/api/products", - payload); + payload, ct: TestContext.Current.CancellationToken); capturedOptions.Should().NotBeNull(); @@ -159,7 +159,7 @@ public async Task Send_Should_Return_Success_Response() SetupResponse(response); var result = await CreateClient() - .SendAsync("GET", "/api/products/1"); + .SendAsync("GET", "/api/products/1", ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(response); } @@ -177,7 +177,7 @@ public async Task Send_Should_Return_Http_Error_Response() SetupResponse(response); var result = await CreateClient() - .SendAsync("GET", "/api/products"); + .SendAsync("GET", "/api/products", ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(response); result.Status.Should().Be(403); @@ -332,8 +332,8 @@ public async Task Send_Should_Generate_Unique_RequestIds() var client = CreateClient(); - await client.SendAsync("GET", "/api/products/1"); - await client.SendAsync("GET", "/api/products/2"); + await client.SendAsync("GET", "/api/products/1", ct: TestContext.Current.CancellationToken); + await client.SendAsync("GET", "/api/products/2", ct: TestContext.Current.CancellationToken); requestIds.Should().HaveCount(2); requestIds.Distinct().Should().HaveCount(2); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index 8f25e4a3..5e1bc430 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -45,8 +45,8 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); - await manager.EnsureAsync(); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); flowClient.Verify(x => x.ValidateAsync(), Times.Once); } @@ -70,9 +70,9 @@ public async Task EnsureAsync_Should_Deduplicate_InFlight_Calls_But_Allow_Later_ Snapshot = CreateSnapshot() }); - var first = manager.EnsureAsync(force: true); - var second = manager.EnsureAsync(force: true); - var third = manager.EnsureAsync(force: true); + var first = manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + var second = manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + var third = manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); first.IsCompleted.Should().BeFalse(); @@ -93,7 +93,7 @@ public async Task EnsureAsync_Should_Deduplicate_InFlight_Calls_But_Allow_Later_ manager.State.IsAuthenticated.Should().BeTrue(); - await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); flows.Verify( x => x.ValidateAsync(), @@ -115,7 +115,7 @@ public async Task EnsureAsync_invalid_should_clear_state() }); var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeFalse(); } @@ -127,7 +127,7 @@ public async Task OnLogin_Should_Mark_Authenticated_State_Stale() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeTrue(); manager.State.IsStale.Should().BeFalse(); @@ -160,7 +160,7 @@ public async Task OnLogout_Should_Clear_Authenticated_State() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await manager.OnLogoutAsync(); manager.State.IsAuthenticated.Should().BeFalse(); @@ -178,11 +178,11 @@ public async Task MarkStale_Should_Cause_Next_Ensure_To_Validate() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.MarkStale(); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); flows.Verify(x => x.ValidateAsync(), Times.Exactly(2)); manager.State.IsStale.Should().BeFalse(); @@ -197,14 +197,14 @@ public async Task Clear_Should_Require_Next_Validation() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.Clear(); manager.State.IsAuthenticated.Should().BeFalse(); manager.NeedsValidation.Should().BeTrue(); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); flows.Verify(x => x.ValidateAsync(), Times.Exactly(2)); } @@ -222,7 +222,7 @@ public async Task State_Invalidating_Events_Should_Clear_State(UAuthStateEvent e { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgsEmpty( @@ -242,7 +242,7 @@ public async Task ProfileChanged_Should_Patch_Current_Identity() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgs( @@ -268,7 +268,7 @@ public async Task UserStatusChanged_Should_Patch_Current_Identity() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgs( @@ -294,7 +294,7 @@ public async Task RefreshMode_None_Should_Ignore_Event() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgsEmpty( @@ -314,7 +314,7 @@ public async Task RefreshMode_Validate_Should_Force_Validation() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgsEmpty( @@ -333,7 +333,7 @@ public async Task ValidationCalled_With_Patch_Should_Mark_Validated() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.MarkStale(); @@ -362,7 +362,7 @@ public async Task AuthorizationChanged_With_Patch_Should_Mark_Stale() { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); await events.PublishAsync( new UAuthStateEventArgsEmpty( @@ -381,7 +381,7 @@ public async Task Dispose_Should_Unsubscribe_From_State_Events() SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.Dispose(); @@ -401,9 +401,9 @@ public async Task EnsureAsync_Should_Allow_Revalidation_After_Synchronous_Comple using (manager) { SetupValidSnapshot(flows); - await manager.EnsureAsync(force: true); - await manager.EnsureAsync(force: true); - await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); flows.Verify(x => x.ValidateAsync(), Times.Exactly(3)); } } @@ -424,7 +424,7 @@ public async Task EnsureAsync_When_Session_Is_Not_Active_Should_Clear_State(Sess { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeTrue(); @@ -434,7 +434,7 @@ public async Task EnsureAsync_When_Session_Is_Not_Active_Should_Clear_State(Sess State = sessionState }); - await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeFalse(); manager.State.Identity.Should().BeNull(); @@ -471,7 +471,7 @@ await act.Should() manager.State.IsAuthenticated.Should().BeFalse(); - await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeTrue(); manager.State.IsStale.Should().BeFalse(); @@ -491,7 +491,7 @@ public async Task EnsureAsync_When_Network_Fails_Should_Preserve_Authenticated_S { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); var originalIdentity = manager.State.Identity; var originalValidatedAt = manager.State.LastValidatedAt; @@ -520,7 +520,7 @@ public async Task EnsureAsync_When_Active_Result_Has_No_Snapshot_Should_Mark_Sta { SetupValidSnapshot(flows); - await manager.EnsureAsync(); + await manager.EnsureAsync(ct: TestContext.Current.CancellationToken); flows.Setup(x => x.ValidateAsync()) .ReturnsAsync(new AuthValidationResult @@ -529,7 +529,7 @@ public async Task EnsureAsync_When_Active_Result_Has_No_Snapshot_Should_Mark_Sta Snapshot = null }); - await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true, ct: TestContext.Current.CancellationToken); manager.State.IsAuthenticated.Should().BeTrue(); manager.State.IsStale.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthUrlBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthUrlBuilderTests.cs new file mode 100644 index 00000000..0a7a7054 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthUrlBuilderTests.cs @@ -0,0 +1,84 @@ +ο»Ώusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client; + +public class UAuthUrlBuilderTests +{ + [Theory] + [InlineData("/auth", "/login", "/acme/auth/login")] + [InlineData("/auth/", "/login", "/acme/auth/login")] + [InlineData("https://localhost:6110/auth", "/login", "https://localhost:6110/acme/auth/login")] + [InlineData("https://example.com/api/auth", "/refresh", "https://example.com/acme/api/auth/refresh")] + [InlineData("https://example.com/auth/", "/pkce/authorize", "https://example.com/acme/auth/pkce/authorize")] + public void Build_RouteTenant_InsertsTenantBeforeBasePath(string basePath, string endpoint, string expected) + { + var tenant = new UAuthClientMultiTenantOptions + { + Enabled = true, + Tenant = "acme", + Transport = TenantTransport.Route + }; + + var result = UAuthUrlBuilder.Build( + basePath, endpoint, tenant); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(false, TenantTransport.Route)] + [InlineData(true, TenantTransport.Header)] + [InlineData(true, TenantTransport.None)] + public void Build_WithoutRouteTenant_PreservesUrl( + bool enabled, TenantTransport transport) + { + var tenant = new UAuthClientMultiTenantOptions + { + Enabled = enabled, + Tenant = "acme", + Transport = transport + }; + + var result = UAuthUrlBuilder.Build( + "https://example.com/auth", "/login", tenant); + + Assert.Equal("https://example.com/auth/login", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Build_RouteTenantWithoutValue_Throws(string? value) + { + var tenant = new UAuthClientMultiTenantOptions + { + Enabled = true, + Tenant = value, + Transport = TenantTransport.Route + }; + + Assert.Throws(() => + UAuthUrlBuilder.Build("/auth", "/login", tenant)); + } + + [Theory] + [InlineData("foo/bar")] + [InlineData("../admin")] + [InlineData("foo?bar")] + [InlineData("foo#bar")] + public void Build_InvalidTenantSegment_Throws(string value) + { + var tenant = new UAuthClientMultiTenantOptions + { + Enabled = true, + Tenant = value, + Transport = TenantTransport.Route + }; + + Assert.Throws(() => + UAuthUrlBuilder.Build("/auth", "/login", tenant)); + } +} 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 6940539a..bf71f5b0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -1,28 +1,18 @@ ο»Ώ - net10.0 enable enable false - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 4ce16d91..b506366e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -38,7 +38,7 @@ public async Task Invalid_When_Token_Not_Found() RefreshToken = "non-existing", Now = DateTimeOffset.UtcNow, Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); Assert.Equal(RefreshTokenValidationState.NotFound, result.State); @@ -69,7 +69,7 @@ public async Task Invalid_When_Token_Is_Revoked_Without_Replacement() now.AddMinutes(5)); await store.StoreAsync( - token.Revoke(now)); + token.Revoke(now), ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -84,7 +84,7 @@ await store.StoreAsync( null, null, null) - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); Assert.Equal( @@ -119,7 +119,7 @@ public async Task Consumed_When_Token_Was_Replaced_By_Rotation() await store.StoreAsync( token.Revoke( now, - "replacement-refresh-token-hash")); + "replacement-refresh-token-hash"), ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -134,7 +134,7 @@ await store.StoreAsync( null, null, null) - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); Assert.Equal( @@ -180,7 +180,7 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() now, now.AddMinutes(10)); - await store.StoreAsync(token); + await store.StoreAsync(token, ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -198,7 +198,7 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() null, null, null), - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); @@ -238,7 +238,7 @@ public async Task Invalid_When_Token_Is_Expired() now.AddMinutes(-10), now.AddMinutes(-1)); - await store.StoreAsync(token); + await store.StoreAsync(token, ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -253,7 +253,7 @@ public async Task Invalid_When_Token_Is_Expired() null, null, null), - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); @@ -286,7 +286,7 @@ public async Task Valid_When_Token_Is_Active() now, now.AddMinutes(10)); - await store.StoreAsync(token); + await store.StoreAsync(token, ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -295,7 +295,7 @@ public async Task Valid_When_Token_Is_Active() RefreshToken = raw, Now = now, Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), - }); + }, ct: TestContext.Current.CancellationToken); Assert.True(result.IsValid); Assert.Equal(RefreshTokenValidationState.Valid, result.State); @@ -325,7 +325,7 @@ public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() now, now.AddMinutes(10)); - await store.StoreAsync(token.Revoke(now, "new-hash")); + await store.StoreAsync(token.Revoke(now, "new-hash"), ct: TestContext.Current.CancellationToken); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -334,7 +334,7 @@ public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() RefreshToken = raw, Now = now, Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), - }); + }, ct: TestContext.Current.CancellationToken); Assert.False(result.IsValid); Assert.Equal(RefreshTokenValidationState.Consumed, result.State); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs index 1af5ece3..4609b239 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -24,7 +24,7 @@ public async Task Change_password_with_correct_current_should_succeed() { CurrentSecret = "user", NewSecret = "newpass123" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -75,7 +75,7 @@ public async Task Change_password_should_increment_version() var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); - var before = await service.GetAllAsync(context); + var before = await service.GetAllAsync(context, ct: TestContext.Current.CancellationToken); var versionBefore = before.Credentials.Single().Version; await service.ChangeSecretAsync(context, @@ -83,9 +83,9 @@ await service.ChangeSecretAsync(context, { CurrentSecret = "user", NewSecret = "newpass123" - }); + }, ct: TestContext.Current.CancellationToken); - var after = await service.GetAllAsync(context); + var after = await service.GetAllAsync(context, ct: TestContext.Current.CancellationToken); var versionAfter = after.Credentials.Single().Version; versionAfter.Should().Be(versionBefore + 1); @@ -106,14 +106,14 @@ await service.ChangeSecretAsync(context, { CurrentSecret = "user", NewSecret = "newpass123" - }); + }, ct: TestContext.Current.CancellationToken); var oldLogin = await orchestrator.LoginAsync(flow, new LoginRequest { Identifier = "user", Secret = "user" - }); + }, ct: TestContext.Current.CancellationToken); oldLogin.IsSuccess.Should().BeFalse(); @@ -122,7 +122,7 @@ await service.ChangeSecretAsync(context, { Identifier = "user", Secret = "newpass123" - }); + }, ct: TestContext.Current.CancellationToken); newLogin.IsSuccess.Should().BeTrue(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 23c7c56b..d4ffdc60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -1,4 +1,5 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core; +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; @@ -8,12 +9,16 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; -using AwesomeAssertions; using Microsoft.Extensions.Options; using Moq; @@ -49,7 +54,7 @@ public async Task GetAllAsync_ReturnsCredentialsForTargetUser() It.IsAny())) .ReturnsAsync(new[] { credential }); - var result = await f.Sut.GetAllAsync(context); + var result = await f.Sut.GetAllAsync(context, ct: TestContext.Current.CancellationToken); result.Credentials.Should().ContainSingle(); @@ -93,7 +98,7 @@ public async Task AddAsync_HashesSecretAndPersistsCredentialForTargetUser() new AddCredentialRequest { Secret = "new-password" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -241,6 +246,18 @@ public async Task ChangeSecretAsync_WhenNewSecretMatchesCurrent_ThrowsValidation "same-password")) .Returns(true); + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "same-password" && + r.ExistingCredentialKey.HasValue), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was previously used."))); + var act = () => f.Sut.ChangeSecretAsync( context, new ChangeCredentialRequest @@ -252,7 +269,7 @@ public async Task ChangeSecretAsync_WhenNewSecretMatchesCurrent_ThrowsValidation var exception = await act.Should() .ThrowAsync(); - exception.Which.Code.Should().Be("credential_secret_same"); + exception.Which.Code.Should().Be(PasswordPolicyErrorCodes.ReusedPassword); f.CredentialStore.Verify( x => x.SaveAsync( @@ -324,7 +341,7 @@ public async Task ChangeSecretAsync_Self_WhenValid_SavesAndRevokesOtherChains() { CurrentSecret = "current-password", NewSecret = "new-password" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -370,7 +387,7 @@ public async Task ChangeSecretAsync_SelfWithoutActorChain_RevokesAllChains() { CurrentSecret = "current-password", NewSecret = "new-password" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -438,14 +455,14 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ { CurrentSecret = null, NewSecret = "new-password" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); f.Hasher.Verify(x => x.Verify( f.OldPasswordHash, It.IsAny()), - Times.Once); + Times.Never); f.SessionStore.Verify(x => x.RevokeAllChainsAsync( target, @@ -460,6 +477,14 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ It.IsAny>(), It.IsAny()), Times.Once); + + f.PasswordPipeline.Verify( + x => x.ValidateAsync( + It.Is(r => + r.ExistingCredentialKey.HasValue), + It.IsAny(), + It.IsAny()), + Times.Once); } // --------------------------------------------------------- @@ -495,7 +520,7 @@ public async Task RevokeAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFou new RevokeCredentialRequest { Id = id - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.Error.Should().Be("credential_not_found"); @@ -545,7 +570,7 @@ public async Task RevokeAsync_WhenValid_RevokesAndSavesExpectedVersion() new RevokeCredentialRequest { Id = id - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -601,7 +626,7 @@ public async Task DeleteAsync_WhenValid_UsesRequestedDeleteMode(DeleteMode mode) { Id = id, Mode = mode - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue( $"delete should succeed but returned '{result.Error}'"); @@ -648,7 +673,7 @@ public async Task DeleteAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFou { Id = id, Mode = DeleteMode.Hard - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.Error.Should().Be("credential_not_found"); @@ -808,7 +833,7 @@ public async Task BeginResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireTarg Identifier = identifier, CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token - }); + }, ct: TestContext.Current.CancellationToken); result.ExpiresAt.Should().BeAfter(Now); @@ -851,7 +876,7 @@ public async Task CompleteResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireT CredentialType = CredentialType.Password, ResetToken = "some-token", NewSecret = "new-password" - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -864,10 +889,296 @@ public async Task CompleteResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireT Times.Never); } - private static LoginIdentifierResolution CreateResolution( - AccessContext context, - string identifier, - UserKey userKey) + [Fact] + public async Task AddAsync_WhenPasswordPolicyRejects_DoesNotHashOrPersist() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.AddSelf); + + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "weak"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.TooShort, + "Password is too short."))); + + var act = () => f.Sut.AddAsync( + context, + new AddCredentialRequest + { + Secret = "weak" + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should() + .Be(PasswordPolicyErrorCodes.TooShort); + + f.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + f.CredentialStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeSecretAsync_WhenPolicyRejects_DoesNotSaveOrRevokeSessions() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + var credential = CreateCredential( + user, + f.OldPasswordHash); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "current-password")) + .Returns(true); + + f.PasswordPipeline + .Setup(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "weak"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.TooShort, + "Password is too short."))); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "current-password", + NewSecret = "weak" + }); + + await act.Should() + .ThrowAsync(); + + f.CredentialStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.SessionStore.Verify( + x => x.RevokeAllChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.SessionStore.Verify( + x => x.RevokeOtherChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteAsync_Hard_RemovesPasswordHistory() + { + var f = CreateFixture(); + var user = UserKey.New(); + var id = Guid.NewGuid(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.DeleteAdmin); + + var credential = CreateCredential( + user, + f.OldPasswordHash, + id: id, + version: 5); + + var key = new CredentialKey( + context.ResourceTenant, + id); + + f.CredentialStore + .Setup(x => x.GetAsync( + key, + It.IsAny())) + .ReturnsAsync(credential); + + f.CredentialStore + .Setup(x => x.DeleteAsync( + key, + 5, + DeleteMode.Hard, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.HistoryStore + .Setup(x => x.DeleteByCredentialAsync( + key, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.DeleteAsync( + context, + new DeleteCredentialRequest + { + Id = id, + Mode = DeleteMode.Hard + }, ct: TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + + f.HistoryStore.Verify( + x => x.DeleteByCredentialAsync( + key, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task AddAsync_UsesEffectivePasswordPolicyOptions() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.AddSelf); + + f.EffectiveOptionsProvider + .Setup(x => x.GetEffective( + context.ResourceTenant, + UAuthClientProfile.NotSpecified)) + .Returns(new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict + } + } + }); + + f.Hasher + .Setup(x => x.Hash("ValidPassword123!")) + .Returns(f.NewPasswordHash); + + f.CredentialStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.AddAsync( + context, + new AddCredentialRequest + { + Secret = "ValidPassword123!" + }, ct: TestContext.Current.CancellationToken); + + f.PasswordPipeline.Verify( + x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "ValidPassword123!"), + It.Is(o => + o.Level == PasswordPolicyLevel.Strict && + o.MinLength == 15), + It.IsAny()), + Times.Once); + } + + + [Fact] + public async Task DeleteInternalAsync_SoftDeletesUserCredentialsWithinAtomicExecution() + { + var f = CreateFixture(); + var tenant = TenantKey.Single; + var user = UserKey.New(); + + var atomicExecutionCalled = false; + + f.AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + async (action, ct) => + { + atomicExecutionCalled = true; + await action(ct); + }); + + f.CredentialStore + .Setup(x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var service = (IUserCredentialsInternalService)f.Sut; + + var result = await service.DeleteInternalAsync( + tenant, + user, + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + atomicExecutionCalled.Should().BeTrue(); + + f.CredentialStoreFactory.Verify( + x => x.Create(tenant), + Times.Once); + + f.CredentialStore.Verify( + x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny()), + Times.Once); + + f.HistoryStoreFactory.VerifyNoOtherCalls(); + f.AccessOrchestrator.VerifyNoOtherCalls(); + } + + + // --------------------------------------------------------- + // Helpers + // --------------------------------------------------------- + + private static LoginIdentifierResolution CreateResolution(AccessContext context, string identifier, UserKey userKey) { return new LoginIdentifierResolution { @@ -880,16 +1191,7 @@ private static LoginIdentifierResolution CreateResolution( }; } - - // --------------------------------------------------------- - // Helpers - // --------------------------------------------------------- - - private static PasswordCredential CreateCredential( - UserKey user, - PasswordHash hash, - Guid? id = null, - long version = 0) + private static PasswordCredential CreateCredential(UserKey user, PasswordHash hash, Guid? id = null, long version = 0) { return PasswordCredential.FromProjection( id: id ?? Guid.NewGuid(), @@ -991,6 +1293,21 @@ private sealed class Fixture public Mock AtomicExecutor { get; } = new(MockBehavior.Strict); + public Mock HistoryStoreFactory { get; } + = new(MockBehavior.Strict); + + public Mock HistoryStore { get; } + = new(MockBehavior.Strict); + + public Mock PasswordContextProvider { get; } + = new(MockBehavior.Strict); + + public Mock PasswordPipeline { get; } + = new(MockBehavior.Strict); + + public Mock EffectiveOptionsProvider { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1100,9 +1417,71 @@ public Fixture() .Returns>, CancellationToken>( (action, ct) => action(ct)); + AtomicExecutor + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, + CancellationToken>( + (action, ct) => action(ct)); + + HistoryStoreFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(HistoryStore.Object); + + HistoryStore + .Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + HistoryStore + .Setup(x => x.DeleteByCredentialAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + PasswordContextProvider + .Setup(x => x.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (tenant, user, password, ct) => + Task.FromResult(new PasswordPolicyContext + { + Password = password + })); + + PasswordPipeline + .Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + var options = Options.Create( TestServerOptions.Default()); + EffectiveOptionsProvider + .Setup(x => x.GetEffective( + It.IsAny(), + It.IsAny())) + .Returns((TenantKey tenant, UAuthClientProfile profile) => + new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = options.Value.Clone() + }); + + var passwordWriter = new PasswordCredentialWriter( + PasswordPipeline.Object, + CredentialStoreFactory.Object, + HistoryStoreFactory.Object, + Hasher.Object); + Sut = new CredentialManagementService( AccessOrchestrator.Object, CredentialStoreFactory.Object, @@ -1116,6 +1495,10 @@ public Fixture() ResetNotifier.Object, CredentialSessionPolicy.Object, AtomicExecutor.Object, + passwordWriter, + HistoryStoreFactory.Object, + PasswordContextProvider.Object, + EffectiveOptionsProvider.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs new file mode 100644 index 00000000..4fa079f5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/EfCorePasswordHistoryAtomicityTests.cs @@ -0,0 +1,178 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class EfCorePasswordHistoryAtomicityTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string value) => + PasswordHash.Create("test", value); + + private static EffectivePasswordPolicyOptions Options(int rememberLast) => + PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + + [Theory] + [InlineData("add")] + [InlineData("prune")] + public async Task FailedHistoryOperation_RollsBackCredentialAndHistory( + string failurePoint) + { + await using var connection = + new SqliteConnection("Data Source=:memory:"); + + await connection.OpenAsync(cancellationToken: TestContext.Current.CancellationToken); + + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + Guid credentialId; + Guid originalHistoryId; + + await using (var db = new UAuthCredentialDbContext(dbOptions)) + { + await db.Database.EnsureCreatedAsync(cancellationToken: TestContext.Current.CancellationToken); + + var user = UserKey.New(); + + var credential = PasswordCredential.Create( + id: Guid.NewGuid(), + tenant: Tenant, + userKey: user, + secretHash: Hash("current"), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + + credentialId = credential.Id; + + var credentials = new EfCorePasswordCredentialStore( + db, new TenantExecutionContext(Tenant)); + + var history = new EfCorePasswordHistoryStore( + db, Tenant); + + await credentials.AddAsync(credential, ct: TestContext.Current.CancellationToken); + + originalHistoryId = Guid.NewGuid(); + + await history.AddAsync(new PasswordHistoryEntry + { + Id = originalHistoryId, + Tenant = Tenant, + UserKey = user, + CredentialKey = new CredentialKey(Tenant, credentialId), + SecretHash = Hash("older"), + ReplacedAt = Now.AddDays(-1) + }, ct: TestContext.Current.CancellationToken); + + var pipeline = new Mock(); + pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + + var hasher = new Mock(); + hasher.Setup(x => x.Hash(It.IsAny())) + .Returns((string password) => Hash(password)); + + var credentialFactory = new Mock(); + credentialFactory.Setup(x => x.Create(Tenant)) + .Returns(credentials); + + var historyFactory = new Mock(); + + var failingHistory = new Mock(); + + failingHistory.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns( + (entry, ct) => + failurePoint == "add" + ? Task.FromException( + new InvalidOperationException("Injected add failure")) + : history.AddAsync(entry, ct)); + + failingHistory.Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (key, count, ct) => + failurePoint == "prune" + ? Task.FromException( + new InvalidOperationException("Injected prune failure")) + : history.PruneAsync(key, count, ct)); + + historyFactory.Setup(x => x.Create(Tenant)) + .Returns(failingHistory.Object); + + var writer = new PasswordCredentialWriter( + pipeline.Object, + credentialFactory.Object, + historyFactory.Object, + hasher.Object); + + await using var transaction = + await db.Database.BeginTransactionAsync(cancellationToken: TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + writer.ChangeAsync( + credential, + "new-password", + new PasswordPolicyContext + { + Password = "new-password" + }, + Options(2), + Now.AddMinutes(1), + TestContext.Current.CancellationToken)); + + // Cleanup must complete even when the test is cancelled. + await transaction.RollbackAsync(); + } + + // Verify using a fresh DbContext, not tracked entities. + await using (var verifyDb = new UAuthCredentialDbContext(dbOptions)) + { + var savedCredential = await verifyDb.PasswordCredentials + .AsNoTracking() + .SingleAsync(x => x.Id == credentialId, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(Hash("current"), savedCredential.SecretHash); + Assert.Equal(0, savedCredential.Version); + + var history = await verifyDb.PasswordHistory + .AsNoTracking() + .Where(x => x.CredentialId == credentialId) + .ToListAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Single(history); + Assert.Equal(originalHistoryId, history[0].Id); + Assert.Equal(Hash("older"), history[0].SecretHash); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs new file mode 100644 index 00000000..e2fefb40 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordCredentialWriterTests.cs @@ -0,0 +1,317 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +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; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordCredentialWriterTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string value) => + PasswordHash.Create("test", value); + + private static EffectivePasswordPolicyOptions Options( + int rememberLast = 0) => + PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + + private static PasswordPolicyContext Context() => new() + { + Password = "" + }; + + private static PasswordCredential Credential() + { + return PasswordCredential.Create( + id: Guid.NewGuid(), + tenant: Tenant, + userKey: UserKey.New(), + secretHash: Hash("old"), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + } + + private sealed class Fixture + { + public Mock Pipeline { get; } = new(); + public Mock Credentials { get; } = new(); + public Mock CredentialFactory { get; } = new(); + public Mock History { get; } = new(); + public Mock HistoryFactory { get; } = new(); + public Mock Hasher { get; } = new(); + + public PasswordCredentialWriter Writer { get; } + + public Fixture() + { + Pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Success()); + + CredentialFactory.Setup(x => x.Create(Tenant)) + .Returns(Credentials.Object); + + HistoryFactory.Setup(x => x.Create(Tenant)) + .Returns(History.Object); + + Hasher.Setup(x => x.Hash(It.IsAny())) + .Returns((string password) => Hash(password)); + + Writer = new PasswordCredentialWriter( + Pipeline.Object, + CredentialFactory.Object, + HistoryFactory.Object, + Hasher.Object); + } + } + + [Fact] + public async Task Create_ValidatesAndPersistsHashedPassword() + { + var f = new Fixture(); + var user = UserKey.New(); + + PasswordCredential? saved = null; + + f.Credentials + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (credential, _) => saved = credential) + .Returns(Task.CompletedTask); + + var result = await f.Writer.CreateAsync( + Tenant, user, "new-password", + Context(), Options(3), Now, TestContext.Current.CancellationToken); + + Assert.Same(result, saved); + Assert.Equal(user, result.UserKey); + Assert.Equal(Hash("new-password"), result.SecretHash); + + f.Pipeline.Verify(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "new-password" && + r.ExistingCredentialKey == null), + It.IsAny(), + It.IsAny()), Times.Once); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WithHistory_StoresOldHashAndPrunes() + { + var f = new Fixture(); + var credential = Credential(); + var originalHash = credential.SecretHash; + var key = new CredentialKey(Tenant, credential.Id); + + PasswordHistoryEntry? added = null; + var calls = new List(); + + f.Credentials.Setup(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => calls.Add("save")) + .Returns(Task.CompletedTask); + + f.History.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (entry, _) => + { + calls.Add("history"); + added = entry; + }) + .Returns(Task.CompletedTask); + + f.History.Setup(x => x.PruneAsync( + key, 3, It.IsAny())) + .Callback(() => calls.Add("prune")) + .Returns(Task.CompletedTask); + + var updated = await f.Writer.ChangeAsync( + credential, "new-password", + Context(), Options(3), + Now.AddMinutes(1), TestContext.Current.CancellationToken); + + Assert.Equal(Hash("new-password"), updated.SecretHash); + Assert.NotNull(added); + Assert.NotEqual(Guid.Empty, added.Id); + Assert.Equal(originalHash, added.SecretHash); + Assert.Equal(key, added.CredentialKey); + Assert.Equal(credential.UserKey, added.UserKey); + Assert.Equal(Tenant, added.Tenant); + Assert.Equal(Now.AddMinutes(1), added.ReplacedAt); + + Assert.Equal(new[] { "save", "history", "prune" }, calls); + + f.Credentials.Verify(x => x.SaveAsync( + It.IsAny(), + 0, + It.IsAny()), Times.Once); + + f.Pipeline.Verify(x => x.ValidateAsync( + It.Is(r => + r.Context.Password == "new-password" && + r.ExistingCredentialKey == key), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Change_WithoutHistory_PrunesAllButDoesNotAdd() + { + var f = new Fixture(); + var credential = Credential(); + var key = new CredentialKey(Tenant, credential.Id); + + await f.Writer.ChangeAsync( + credential, "new-password", + Context(), Options(0), + Now.AddMinutes(1), TestContext.Current.CancellationToken); + + f.History.Verify(x => x.AddAsync( + It.IsAny(), + It.IsAny()), Times.Never); + + f.History.Verify(x => x.PruneAsync( + key, 0, It.IsAny()), Times.Once); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidationFailure_PreventsPersistence( + bool change) + { + var f = new Fixture(); + + f.Pipeline.Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was used previously."))); + + var exception = await Assert.ThrowsAsync( + async () => + { + if (change) + { + await f.Writer.ChangeAsync( + Credential(), "reused", + Context(), Options(3), Now, TestContext.Current.CancellationToken); + } + else + { + await f.Writer.CreateAsync( + Tenant, UserKey.New(), "reused", + Context(), Options(3), Now, TestContext.Current.CancellationToken); + } + }); + + Assert.Equal(PasswordPolicyErrorCodes.ReusedPassword, exception.Code); + + f.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + f.CredentialFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WhenCredentialSaveFails_DoesNotWriteHistory() + { + var f = new Fixture(); + + f.Credentials.Setup(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("save failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, TestContext.Current.CancellationToken)); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Change_WhenHistoryAddFails_DoesNotPrune() + { + var f = new Fixture(); + + f.History.Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("history failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, TestContext.Current.CancellationToken)); + + f.History.Verify(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task Change_WhenPruneFails_PropagatesFailure() + { + var f = new Fixture(); + + f.History.Setup(x => x.PruneAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("prune failed")); + + await Assert.ThrowsAsync(() => + f.Writer.ChangeAsync( + Credential(), "new-password", + Context(), Options(3), Now, TestContext.Current.CancellationToken)); + + f.History.Verify(x => x.AddAsync( + It.IsAny(), + It.IsAny()), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs new file mode 100644 index 00000000..8dd1214c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyInfoMappingTests.cs @@ -0,0 +1,69 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordPolicyInfoMappingTests +{ + [Fact] + public void StandardPolicy_MapsExpectedRequirements() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions()); + + var info = effective.ToInfo(); + + Assert.Equal(PasswordPolicyLevel.Standard, info.Level); + Assert.Equal(8, info.MinLength); + Assert.Equal(128, info.MaxLength); + Assert.True(info.RequireLetter); + Assert.True(info.RequireDigit); + Assert.False(info.RequireUppercase); + Assert.False(info.RequireLowercase); + Assert.False(info.RequireSpecialCharacter); + } + + [Fact] + public void StrictOverrides_ArePreserved() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict, + MinLength = 12, + MaxLength = 64, + RequireDigit = false + }); + + var info = effective.ToInfo(); + + Assert.Equal(PasswordPolicyLevel.Strict, info.Level); + Assert.Equal(12, info.MinLength); + Assert.Equal(64, info.MaxLength); + Assert.False(info.RequireDigit); + Assert.True(info.RequireUppercase); + Assert.True(info.RequireLowercase); + Assert.True(info.RequireSpecialCharacter); + } + + [Fact] + public void OptionalChecks_AreMapped() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + CommonPasswords = PasswordPolicyLevel.Disabled, + UserSimilarity = PasswordPolicyLevel.Strict, + History = new PasswordHistoryOptions + { + RememberLast = 5 + } + }); + + var info = effective.ToInfo(); + + Assert.False(info.CheckCommonPasswords); + Assert.True(info.CheckUserSimilarity); + Assert.True(info.CheckPasswordHistory); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs new file mode 100644 index 00000000..6e561909 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordPolicyOptionsTests.cs @@ -0,0 +1,332 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class PasswordPolicyOptionsTests +{ + private readonly UAuthServerPasswordPolicyOptionsValidator _validator = new(); + + [Fact] + public void DefaultOptions_ResolveToStandard() + { + var effective = PasswordPolicyOptionsResolver.Resolve(new()); + + Assert.Equal(PasswordPolicyLevel.Standard, effective.Level); + Assert.Equal(8, effective.MinLength); + Assert.Equal(128, effective.MaxLength); + Assert.True(effective.RequireLetter); + Assert.True(effective.RequireDigit); + Assert.False(effective.RequireSpecialCharacter); + Assert.Equal(0, effective.RememberLast); + } + + [Fact] + public void BasicPreset_DoesNotRequireCharacterClasses() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Basic + }); + + Assert.False(effective.RequireLetter); + Assert.False(effective.RequireDigit); + Assert.False(effective.RequireUppercase); + Assert.False(effective.RequireSpecialCharacter); + } + + [Fact] + public void Overrides_TakePrecedenceOverPreset() + { + var effective = PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Strict, + MinLength = 12, + RequireDigit = false, + CommonPasswords = PasswordPolicyLevel.Basic + }); + + Assert.Equal(12, effective.MinLength); + Assert.False(effective.RequireDigit); + Assert.True(effective.RequireUppercase); + Assert.Equal(PasswordPolicyLevel.Basic, effective.CommonPasswords); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Disabled)] + [InlineData((PasswordPolicyLevel)999)] + public void InvalidGlobalLevel_IsRejected(PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions { Level = level } + }); + + Assert.True(result.Failed); + } + + [Theory] + [InlineData(5, 128)] + [InlineData(8, 257)] + [InlineData(150, 128)] + public void InvalidLengths_AreRejected(int min, int max) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + MinLength = min, + MaxLength = max + } + }); + + Assert.True(result.Failed); + } + + [Theory] + [InlineData(-1)] + [InlineData(11)] + public void InvalidHistoryCount_IsRejected(int count) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = count + } + } + }); + + Assert.True(result.Failed); + } + + [Fact] + public void DisabledIndividualCheck_IsAllowed() + { + var options = new PasswordPolicyOptions + { + UserSimilarity = PasswordPolicyLevel.Disabled + }; + + var result = _validator.Validate(null, new UAuthServerOptions { Password = options }); + + Assert.True(result.Succeeded); + } + + + [Fact] + public void NullServerOptions_IsRejected() + { + var result = _validator.Validate(null, null!); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("cannot be null")); + } + + [Fact] + public void NullHistoryOptions_IsRejected() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + History = null! + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("History")); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidCommonPasswordsLevel_IsRejected(int level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + CommonPasswords = (PasswordPolicyLevel)level + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("CommonPasswords")); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidUserSimilarityLevel_IsRejected(int level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + UserSimilarity = (PasswordPolicyLevel)level + } + }); + + Assert.True(result.Failed); + Assert.Contains( + result.Failures, + x => x.Contains("UserSimilarity")); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void ValidGlobalLevels_AreAccepted( + PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = level + } + }); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(0)] + [InlineData(10)] + public void ValidHistoryBoundaryValues_AreAccepted(int count) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + History = new PasswordHistoryOptions + { + RememberLast = count + } + } + }); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(PasswordPolicyLevel.Disabled)] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void ValidIndividualCheckLevels_AreAccepted( + PasswordPolicyLevel level) + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + CommonPasswords = level, + UserSimilarity = level + } + }); + + Assert.True(result.Succeeded); + } + + [Fact] + public void ValidLengthBoundaryValues_AreAccepted() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + MinLength = PasswordPolicyLimits.MinimumLength, + MaxLength = PasswordPolicyLimits.AbsoluteMaxLength + } + }); + + Assert.True(result.Succeeded); + } + + [Fact] + public void NonPositiveMaxLength_IsRejected() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + MaxLength = 0 + } + }); + + Assert.True(result.Failed); + + Assert.Contains( + result.Failures, + x => x.Contains("positive")); + } + + [Fact] + public void MultipleInvalidOptions_ReportAllRelevantFailures() + { + var result = _validator.Validate( + null, + new UAuthServerOptions + { + Password = new PasswordPolicyOptions + { + Level = (PasswordPolicyLevel)999, + CommonPasswords = (PasswordPolicyLevel)999, + UserSimilarity = (PasswordPolicyLevel)999, + History = new PasswordHistoryOptions + { + RememberLast = -1 + } + } + }); + + Assert.True(result.Failed); + + Assert.Equal(4, result.Failures.Count()); + + Assert.Contains(result.Failures, + x => x.Contains("Level")); + Assert.Contains(result.Failures, + x => x.Contains("CommonPasswords")); + Assert.Contains(result.Failures, + x => x.Contains("UserSimilarity")); + Assert.Contains(result.Failures, + x => x.Contains("history count")); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs new file mode 100644 index 00000000..e2f0bb94 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/PasswordValidationPipelineTests.cs @@ -0,0 +1,203 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class PasswordValidationPipelineTests +{ + private sealed class AcceptAllStrengthPolicy : IPasswordStrengthPolicy + { + public int CallCount { get; private set; } + + public Task ValidateAsync( + PasswordPolicyContext context, + EffectivePasswordPolicyOptions options, + CancellationToken ct = default) + { + CallCount++; + + return Task.FromResult(PasswordPolicyResult.Success()); + } + } + + private sealed class RecordingHistoryValidator : IPasswordHistoryValidator + { + public int CallCount { get; private set; } + + public CredentialKey? LastCredentialKey { get; private set; } + + public string? LastPassword { get; private set; } + + public int? LastRememberLast { get; private set; } + + public PasswordPolicyResult Result { get; set; } + = PasswordPolicyResult.Success(); + + public Task ValidateAsync( + CredentialKey credentialKey, + string password, + int rememberLast, + CancellationToken ct = default) + { + CallCount++; + LastCredentialKey = credentialKey; + LastPassword = password; + LastRememberLast = rememberLast; + + return Task.FromResult(Result); + } + } + + private static EffectivePasswordPolicyOptions CreateOptions( + int rememberLast = 0) + { + return PasswordPolicyOptionsResolver.Resolve( + new PasswordPolicyOptions + { + Level = PasswordPolicyLevel.Standard, + History = new PasswordHistoryOptions + { + RememberLast = rememberLast + } + }); + } + + private static UAuthPasswordValidationPipeline CreatePipeline( + RecordingHistoryValidator history, + AcceptAllStrengthPolicy? strength = null) + { + return new UAuthPasswordValidationPipeline( + strength ?? new AcceptAllStrengthPolicy(), + history); + } + + private static PasswordValidationRequest CreateRequest( + string password, + CredentialKey? existingCredentialKey = null) + { + return new PasswordValidationRequest + { + Context = new PasswordPolicyContext + { + Password = password + }, + ExistingCredentialKey = existingCredentialKey + }; + } + + [Fact] + public async Task AbsoluteLimit_CannotBeBypassedByCustomStrengthPolicy() + { + var history = new RecordingHistoryValidator(); + var strength = new AcceptAllStrengthPolicy(); + var pipeline = CreatePipeline(history, strength); + + var result = await pipeline.ValidateAsync( + CreateRequest(new string('a', 257)), + CreateOptions(), ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.InputTooLong); + + Assert.Equal(0, strength.CallCount); + Assert.Equal(0, history.CallCount); + } + + [Fact] + public async Task NewCredential_DoesNotRequireHistory() + { + var history = new RecordingHistoryValidator(); + var strength = new AcceptAllStrengthPolicy(); + var pipeline = CreatePipeline(history, strength); + + var result = await pipeline.ValidateAsync( + CreateRequest("NewPassword123!"), + CreateOptions(rememberLast: 5), ct: TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, strength.CallCount); + Assert.Equal(0, history.CallCount); + } + + [Fact] + public async Task ExistingCredential_AlwaysValidatesHistory_WhenRememberLastIsZero() + { + var history = new RecordingHistoryValidator + { + Result = PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password matches the current password.")) + }; + + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("CurrentPassword123!", key), + CreateOptions(rememberLast: 0), ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + Assert.Equal(1, history.CallCount); + Assert.Equal(0, history.LastRememberLast); + Assert.Equal(key, history.LastCredentialKey); + } + + [Fact] + public async Task ExistingCredential_PassesRememberLastToHistoryValidator() + { + var history = new RecordingHistoryValidator(); + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("NewPassword123!", key), + CreateOptions(rememberLast: 5), ct: TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, history.CallCount); + Assert.Equal(key, history.LastCredentialKey); + Assert.Equal("NewPassword123!", history.LastPassword); + Assert.Equal(5, history.LastRememberLast); + } + + [Fact] + public async Task ExistingCredential_PropagatesHistoryValidationFailure() + { + var history = new RecordingHistoryValidator + { + Result = PasswordPolicyResult.Failure( + new PasswordPolicyViolation( + PasswordPolicyErrorCodes.ReusedPassword, + "Password was used previously.")) + }; + + var pipeline = CreatePipeline(history); + var key = new CredentialKey(TenantKey.Single, Guid.NewGuid()); + + var result = await pipeline.ValidateAsync( + CreateRequest("PreviouslyUsed123!", key), + CreateOptions(rememberLast: 3), ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + Assert.Equal(1, history.CallCount); + Assert.Equal(3, history.LastRememberLast); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 52aafab0..abd0ee70 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -24,7 +24,7 @@ public async Task Begin_reset_with_token_should_notify_without_exposing_token() var result = await service.BeginResetAsync( BeginContext(), - BeginRequest(ResetCodeType.Token)); + BeginRequest(ResetCodeType.Token), ct: TestContext.Current.CancellationToken); var notification = notifier.Notifications.Should() .ContainSingle().Subject; @@ -67,7 +67,7 @@ public async Task Begin_reset_with_code_should_notify_numeric_code() await runtime.GetCredentialManagementService().BeginResetAsync( BeginContext(), - BeginRequest(ResetCodeType.Code)); + BeginRequest(ResetCodeType.Code), ct: TestContext.Current.CancellationToken); var notification = notifier.Notifications.Should() .ContainSingle().Subject; @@ -86,7 +86,7 @@ public async Task Begin_reset_for_unknown_user_should_not_notify() BeginContext(), BeginRequest( ResetCodeType.Token, - "unknown@test.com")); + "unknown@test.com"), ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow); @@ -104,33 +104,55 @@ public async Task Reset_password_with_valid_token_should_succeed() var result = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "newpass123")); + CompleteRequest(token, "newpass123"), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } [Fact] - public async Task Reset_password_with_same_password_should_succeed_without_changing_password() + public async Task Reset_password_with_same_password_should_not_consume_token() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); - var result = await service.CompleteResetAsync( + // Same password is rejected internally, but anonymous + // reset must return a generic success response. + var samePassword = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "admin")); + CompleteRequest(token, "admin"), ct: TestContext.Current.CancellationToken); - result.IsSuccess.Should().BeTrue(); + samePassword.IsSuccess.Should().BeTrue(); + + // The credential must remain unchanged. + await AssertPasswordAsync(runtime, userKey, "admin"); + + // A policy failure must not consume the reset token. + var retry = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "DifferentPassword123!"), ct: TestContext.Current.CancellationToken); + + retry.IsSuccess.Should().BeTrue(); - // The reset token must be single-use even when - // the new password matches the existing password. - var secondResult = await service.CompleteResetAsync( + await AssertPasswordAsync( + runtime, userKey, "DifferentPassword123!"); + + // A successfully consumed token must not be reusable. + var replay = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "different-password")); + CompleteRequest(token, "AnotherPassword123!"), ct: TestContext.Current.CancellationToken); + + replay.IsSuccess.Should().BeTrue(); + + await AssertPasswordAsync( + runtime, userKey, "DifferentPassword123!"); - secondResult.IsSuccess.Should().BeTrue(); + await AssertPasswordRejectedAsync( + runtime, userKey, "AnotherPassword123!"); } [Fact] @@ -152,7 +174,7 @@ public async Task Reset_token_should_lock_after_max_attempts() { var result = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(wrongToken, "newpass123")); + CompleteRequest(wrongToken, "newpass123"), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -162,7 +184,7 @@ public async Task Reset_token_should_lock_after_max_attempts() // Even the correct token must no longer work. var afterLockout = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "newpass123")); + CompleteRequest(token, "newpass123"), ct: TestContext.Current.CancellationToken); afterLockout.IsSuccess.Should().BeTrue(); @@ -183,7 +205,7 @@ public async Task Reset_token_should_be_single_use() var first = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "newpass123")); + CompleteRequest(token, "newpass123"), ct: TestContext.Current.CancellationToken); first.IsSuccess.Should().BeTrue(); @@ -191,7 +213,7 @@ public async Task Reset_token_should_be_single_use() var replay = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "anotherpass")); + CompleteRequest(token, "anotherpass"), ct: TestContext.Current.CancellationToken); replay.IsSuccess.Should().BeTrue(); @@ -219,7 +241,7 @@ public async Task Reset_token_should_not_change_password_if_expired() var result = await service.CompleteResetAsync( CompleteContext(), - CompleteRequest(token, "newpass123")); + CompleteRequest(token, "newpass123"), ct: TestContext.Current.CancellationToken); // Generic anonymous response, not proof of a password change. result.IsSuccess.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..5c2744e2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordHistoryStoreContractTests.cs @@ -0,0 +1,55 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class EfCorePasswordHistoryStoreContractTests + : PasswordHistoryStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : IPasswordHistoryStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthCredentialDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthCredentialDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IPasswordHistoryStore CreateStore(TenantKey tenant) + => new EfCorePasswordHistoryStore( + _db, tenant); + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs new file mode 100644 index 00000000..5aebec66 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordHistoryStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public interface IPasswordHistoryStoreTestDatabase : IAsyncDisposable +{ + IPasswordHistoryStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..eec7fb1b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordHistoryStoreContractTests.cs @@ -0,0 +1,35 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class InMemoryPasswordHistoryStoreContractTests : PasswordHistoryStoreContractTests +{ + protected override Task CreateDatabaseAsync() + { + return Task.FromResult(new Database()); + } + + private sealed class Database : IPasswordHistoryStoreTestDatabase + { + private readonly InMemoryAtomicContextAccessor + _atomicContext = new(); + + private readonly InMemoryPasswordHistoryStoreFactory + _factory; + + public Database() + { + _factory = new InMemoryPasswordHistoryStoreFactory( + _atomicContext); + } + + public IPasswordHistoryStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs index c5901281..b74ac3e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs @@ -56,13 +56,13 @@ public async Task AddAsync_PersistsCredential() var user = UserKey.New(); var credential = CreateCredential(TenantA, user, "credential-1"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var key = new CredentialKey( credential.Tenant, credential.Id); - var persisted = await store.GetAsync(key); + var persisted = await store.GetAsync(key, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Id.Should().Be(credential.Id); @@ -82,11 +82,11 @@ public async Task ExistsAsync_WhenCredentialExists_ReturnsTrue() UserKey.New(), "credential-1"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var key = new CredentialKey(TenantA, credential.Id); - (await store.ExistsAsync(key)).Should().BeTrue(); + (await store.ExistsAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeTrue(); } [Fact] @@ -102,7 +102,7 @@ public async Task ExistsAsync_WhenCredentialDoesNotExist_ReturnsFalse() var key = new CredentialKey(TenantA, credential.Id); - (await store.ExistsAsync(key)).Should().BeFalse(); + (await store.ExistsAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeFalse(); } [Fact] @@ -117,7 +117,7 @@ public async Task GetAsync_WhenCredentialDoesNotExist_ReturnsNull() "missing"); var result = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -140,7 +140,7 @@ public async Task AddAsync_WhenUserAlreadyHasActivePasswordCredential_ThrowsConf var second = CreateCredential( TenantA, user, "second"); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); var act = () => store.AddAsync(second); @@ -159,13 +159,13 @@ public async Task AddAsync_AfterSoftDeletingExistingCredential_AllowsReplacement var first = CreateCredential( TenantA, user, "first"); - await store.AddAsync(first); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new CredentialKey(TenantA, first.Id), first.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var replacement = CreateCredential( TenantA, user, "replacement"); @@ -174,7 +174,7 @@ await store.DeleteAsync( await act.Should().NotThrowAsync(); - var active = await store.GetByUserAsync(user); + var active = await store.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); active.Should().ContainSingle(); active.Single().Id.Should().Be(replacement.Id); @@ -195,7 +195,7 @@ public async Task SaveAsync_WhenExpectedVersionMatches_PersistsChanges() UserKey.New(), "original"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var changedAt = Now.AddMinutes(10); @@ -205,10 +205,10 @@ public async Task SaveAsync_WhenExpectedVersionMatches_PersistsChanges() await store.SaveAsync( credential, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -251,14 +251,14 @@ public async Task SaveAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var changed = credential.Revoke( Now.AddMinutes(5)); await store.SaveAsync( changed, - credential.Version); + credential.Version, ct: TestContext.Current.CancellationToken); var act = () => store.SaveAsync( changed, @@ -283,17 +283,17 @@ public async Task RevokeAsync_RevokesCredential() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var revokedAt = Now.AddMinutes(10); await store.RevokeAsync( new CredentialKey(TenantA, credential.Id), revokedAt, - credential.Version); + credential.Version, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Security.RevokedAt.Should().Be(revokedAt); @@ -331,12 +331,12 @@ public async Task RevokeAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.RevokeAsync( new CredentialKey(TenantA, credential.Id), Now, - 0); + 0, ct: TestContext.Current.CancellationToken); var act = () => store.RevokeAsync( new CredentialKey(TenantA, credential.Id), @@ -361,15 +361,15 @@ public async Task DeleteAsync_SoftDelete_HidesCredentialFromGetByUser() var credential = CreateCredential( TenantA, user, "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new CredentialKey(TenantA, credential.Id), credential.Version, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); - var credentials = await store.GetByUserAsync(user); + var credentials = await store.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); credentials.Should().BeEmpty(); } @@ -385,16 +385,16 @@ public async Task DeleteAsync_SoftDelete_PreservesCredentialRecord() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new CredentialKey(TenantA, credential.Id), 0, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsDeleted.Should().BeTrue(); @@ -413,7 +413,7 @@ public async Task DeleteAsync_HardDelete_RemovesCredential() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); var key = new CredentialKey( TenantA, @@ -423,10 +423,10 @@ await store.DeleteAsync( key, credential.Version, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); - (await store.GetAsync(key)).Should().BeNull(); - (await store.ExistsAsync(key)).Should().BeFalse(); + (await store.GetAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeNull(); + (await store.ExistsAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeFalse(); } [Fact] @@ -440,12 +440,12 @@ public async Task DeleteAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() UserKey.New(), "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.RevokeAsync( new CredentialKey(TenantA, credential.Id), Now, - 0); + 0, ct: TestContext.Current.CancellationToken); var act = () => store.DeleteAsync( new CredentialKey(TenantA, credential.Id), @@ -476,10 +476,10 @@ public async Task GetByUserAsync_ReturnsOnlyActiveNonDeletedCredentialsForUser() var credentialB = CreateCredential( TenantA, userB, "b"); - await store.AddAsync(credentialA); - await store.AddAsync(credentialB); + await store.AddAsync(credentialA, ct: TestContext.Current.CancellationToken); + await store.AddAsync(credentialB, ct: TestContext.Current.CancellationToken); - var result = await store.GetByUserAsync(userA); + var result = await store.GetByUserAsync(userA, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().Id.Should().Be(credentialA.Id); @@ -500,18 +500,18 @@ public async Task DeleteByUserAsync_SoftDelete_DeletesUsersCredentials() var credential = CreateCredential( TenantA, user, "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.DeleteByUserAsync( user, DeleteMode.Soft, - Now); + Now, ct: TestContext.Current.CancellationToken); - var active = await store.GetByUserAsync(user); + var active = await store.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); active.Should().BeEmpty(); var persisted = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsDeleted.Should().BeTrue(); @@ -530,15 +530,15 @@ public async Task DeleteByUserAsync_HardDelete_RemovesUsersCredentials() var credential = CreateCredential( TenantA, user, "credential"); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.DeleteByUserAsync( user, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new CredentialKey(TenantA, credential.Id)); + new CredentialKey(TenantA, credential.Id), ct: TestContext.Current.CancellationToken); persisted.Should().BeNull(); } @@ -558,20 +558,20 @@ public async Task DeleteByUserAsync_DoesNotAffectOtherUsers() var other = CreateCredential( TenantA, otherUser, "other"); - await store.AddAsync(target); - await store.AddAsync(other); + await store.AddAsync(target, ct: TestContext.Current.CancellationToken); + await store.AddAsync(other, ct: TestContext.Current.CancellationToken); await store.DeleteByUserAsync( targetUser, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); (await store.GetAsync( - new CredentialKey(TenantA, target.Id))) + new CredentialKey(TenantA, target.Id), ct: TestContext.Current.CancellationToken)) .Should().BeNull(); (await store.GetAsync( - new CredentialKey(TenantA, other.Id))) + new CredentialKey(TenantA, other.Id), ct: TestContext.Current.CancellationToken)) .Should().NotBeNull(); } @@ -595,11 +595,11 @@ public async Task GetByUserAsync_IsTenantIsolated() var credentialB = CreateCredential( TenantB, user, "b"); - await storeA.AddAsync(credentialA); - await storeB.AddAsync(credentialB); + await storeA.AddAsync(credentialA, ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(credentialB, ct: TestContext.Current.CancellationToken); - var fromA = await storeA.GetByUserAsync(user); - var fromB = await storeB.GetByUserAsync(user); + var fromA = await storeA.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); + var fromB = await storeB.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); fromA.Should().ContainSingle(); fromA.Single().Id.Should().Be(credentialA.Id); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs new file mode 100644 index 00000000..f023807e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordHistoryStoreContractTests.cs @@ -0,0 +1,540 @@ +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +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.Tests.Unit.Credentials.Contracts; + +public abstract class PasswordHistoryStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + private static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + private static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + private static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static CredentialKey Key(TenantKey tenant) => + new(tenant, Guid.NewGuid()); + + private static PasswordHistoryEntry Entry( + CredentialKey key, + UserKey user, + int minutes = 0, + Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), + Tenant = key.Tenant, + UserKey = user, + CredentialKey = key, + SecretHash = PasswordHash.Create( + "test", $"hash-{Guid.NewGuid():N}"), + ReplacedAt = Now.AddMinutes(minutes) + }; + + [Fact] + public async Task AddAsync_PersistsAllFields() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var entry = Entry(key, UserKey.New()); + + await store.AddAsync(entry, ct: TestContext.Current.CancellationToken); + + var stored = (await store.GetRecentAsync(key, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle().Subject; + + stored.Id.Should().Be(entry.Id); + stored.Tenant.Should().Be(entry.Tenant); + stored.UserKey.Should().Be(entry.UserKey); + stored.CredentialKey.Should().Be(entry.CredentialKey); + stored.SecretHash.Should().Be(entry.SecretHash); + stored.ReplacedAt.Should().Be(entry.ReplacedAt); + } + + [Fact] + public async Task GetRecentAsync_ReturnsNewestFirst() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var oldest = Entry(key, user, 1); + var newest = Entry(key, user, 3); + var middle = Entry(key, user, 2); + + await store.AddAsync(newest, ct: TestContext.Current.CancellationToken); + await store.AddAsync(oldest, ct: TestContext.Current.CancellationToken); + await store.AddAsync(middle, ct: TestContext.Current.CancellationToken); + + var result = await store.GetRecentAsync(key, 2, ct: TestContext.Current.CancellationToken); + + result.Select(x => x.Id) + .Should().Equal(newest.Id, middle.Id); + } + + [Fact] + public async Task GetRecentAsync_EqualDates_UsesIdDescending() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var low = Entry(key, user, id: Guid.Parse( + "00000000-0000-0000-0000-000000000001")); + var high = Entry(key, user, id: Guid.Parse( + "00000000-0000-0000-0000-000000000002")); + + await store.AddAsync(low, ct: TestContext.Current.CancellationToken); + await store.AddAsync(high, ct: TestContext.Current.CancellationToken); + + var result = await store.GetRecentAsync(key, 2, ct: TestContext.Current.CancellationToken); + + result.Select(x => x.Id) + .Should().Equal(high.Id, low.Id); + } + + [Fact] + public async Task GetRecentAsync_Zero_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + + await store.AddAsync(Entry(key, UserKey.New()), ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(key, 0, ct: TestContext.Current.CancellationToken)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetRecentAsync_CountGreaterThanAvailable_ReturnsAll() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + + await store.AddAsync(Entry(key, UserKey.New()), ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(key, 100, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(); + } + + [Fact] + public async Task GetRecentAsync_NegativeCount_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.GetRecentAsync(Key(TenantA), -1); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetRecentAsync_MissingCredential_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + (await store.GetRecentAsync(Key(TenantA), 5, ct: TestContext.Current.CancellationToken)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetRecentAsync_DoesNotMixCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + var a = Entry(keyA, user); + await store.AddAsync(a, ct: TestContext.Current.CancellationToken); + await store.AddAsync(Entry(keyB, user), ct: TestContext.Current.CancellationToken); + + var result = await store.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken); + + result.Should().ContainSingle(); + result.Single().Id.Should().Be(a.Id); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(10)] + public async Task PruneAsync_KeepsExactlyNewestEntries( + int keepLast) + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + var entries = Enumerable.Range(0, 5) + .Select(i => Entry(key, user, i)) + .ToArray(); + + foreach (var entry in entries.Reverse()) + await store.AddAsync(entry, ct: TestContext.Current.CancellationToken); + + await store.PruneAsync(key, keepLast, ct: TestContext.Current.CancellationToken); + + var result = await store.GetRecentAsync(key, 20, ct: TestContext.Current.CancellationToken); + var expected = entries + .OrderByDescending(x => x.ReplacedAt) + .Take(keepLast) + .Select(x => x.Id); + + result.Select(x => x.Id).Should().Equal(expected); + } + + [Fact] + public async Task PruneAsync_DoesNotAffectOtherCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(keyA, user), ct: TestContext.Current.CancellationToken); + var other = Entry(keyB, user); + await store.AddAsync(other, ct: TestContext.Current.CancellationToken); + + await store.PruneAsync(keyA, 0, ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task PruneAsync_NegativeCount_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.PruneAsync(Key(TenantA), -1); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(key, user, 1), ct: TestContext.Current.CancellationToken); + await store.AddAsync(Entry(key, user, 2), ct: TestContext.Current.CancellationToken); + + await store.PruneAsync(key, 1, ct: TestContext.Current.CancellationToken); + var first = await store.GetRecentAsync(key, 10, ct: TestContext.Current.CancellationToken); + + await store.PruneAsync(key, 1, ct: TestContext.Current.CancellationToken); + var second = await store.GetRecentAsync(key, 10, ct: TestContext.Current.CancellationToken); + + second.Should().BeEquivalentTo(first); + } + + [Fact] + public async Task DeleteByCredentialAsync_RemovesOnlyTarget() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var keyA = Key(TenantA); + var keyB = Key(TenantA); + var user = UserKey.New(); + + await store.AddAsync(Entry(keyA, user), ct: TestContext.Current.CancellationToken); + await store.AddAsync(Entry(keyA, user), ct: TestContext.Current.CancellationToken); + var other = Entry(keyB, user); + await store.AddAsync(other, ct: TestContext.Current.CancellationToken); + + await store.DeleteByCredentialAsync(keyA, ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task DeleteByCredentialAsync_Missing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var key = Key(TenantA); + + await store.DeleteByCredentialAsync(key, ct: TestContext.Current.CancellationToken); + await store.DeleteByCredentialAsync(key, ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(key, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + } + + [Fact] + public async Task DeleteByUserAsync_RemovesAllUsersCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var userA = UserKey.New(); + var userB = UserKey.New(); + var keyA1 = Key(TenantA); + var keyA2 = Key(TenantA); + var keyB = Key(TenantA); + + await store.AddAsync(Entry(keyA1, userA), ct: TestContext.Current.CancellationToken); + await store.AddAsync(Entry(keyA2, userA), ct: TestContext.Current.CancellationToken); + var other = Entry(keyB, userB); + await store.AddAsync(other, ct: TestContext.Current.CancellationToken); + + await store.DeleteByUserAsync(userA, ct: TestContext.Current.CancellationToken); + + (await store.GetRecentAsync(keyA1, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await store.GetRecentAsync(keyA2, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await store.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(x => x.Id == other.Id); + } + + [Fact] + public async Task DeleteByUserAsync_Missing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + await store.DeleteByUserAsync(user, ct: TestContext.Current.CancellationToken); + await store.DeleteByUserAsync(user, ct: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task TenantIsolation_GetRecentAndPrune() + { + await using var db = await CreateDatabaseAsync(); + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + var user = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var keyA = new CredentialKey(TenantA, credentialId); + var keyB = new CredentialKey(TenantB, credentialId); + + await storeA.AddAsync(Entry(keyA, user), ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(Entry(keyB, user), ct: TestContext.Current.CancellationToken); + + (await storeA.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(); + (await storeB.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(); + + await storeA.PruneAsync(keyA, 0, ct: TestContext.Current.CancellationToken); + + (await storeA.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await storeB.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(); + } + + [Fact] + public async Task TenantIsolation_DeleteByUser() + { + await using var db = await CreateDatabaseAsync(); + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + var user = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var keyA = new CredentialKey(TenantA, credentialId); + var keyB = new CredentialKey(TenantB, credentialId); + + await storeA.AddAsync(Entry(keyA, user), ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(Entry(keyB, user), ct: TestContext.Current.CancellationToken); + + await storeA.DeleteByUserAsync(user, ct: TestContext.Current.CancellationToken); + + (await storeA.GetRecentAsync(keyA, 10, ct: TestContext.Current.CancellationToken)).Should().BeEmpty(); + (await storeB.GetRecentAsync(keyB, 10, ct: TestContext.Current.CancellationToken)) + .Should().ContainSingle(); + } + + [Fact] + public async Task AddAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.AddAsync( + Entry(Key(TenantB), UserKey.New())); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("tenant_mismatch"); + } + + [Fact] + public async Task GetRecentAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.GetRecentAsync(Key(TenantB), 5); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.PruneAsync(Key(TenantB), 1); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByCredentialAsync_WrongTenant_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.DeleteByCredentialAsync(Key(TenantB)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_EmptyId_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var entry = Entry(Key(TenantA), UserKey.New()) + with + { Id = Guid.Empty }; + + var act = () => store.AddAsync(entry); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_NullEntry_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.AddAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_DuplicateId_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + var key = Key(TenantA); + var entry = Entry(key, UserKey.New()); + + await store.AddAsync(entry, ct: TestContext.Current.CancellationToken); + + var act = () => store.AddAsync(entry); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetRecentAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetRecentAsync( + Key(TenantA), 5, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.AddAsync( + Entry(Key(TenantA), UserKey.New()), cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PruneAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.PruneAsync( + Key(TenantA), 1, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByCredentialAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.DeleteByCredentialAsync( + Key(TenantA), cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteByUserAsync_Cancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.DeleteByUserAsync( + UserKey.New(), cts.Token); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs new file mode 100644 index 00000000..25429623 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordClientValidatorTests.cs @@ -0,0 +1,121 @@ +ο»Ώusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordClientValidatorTests +{ + [Fact] + public void ValidPassword_ReturnsNoViolations() + { + var errors = UAuthPasswordClientValidator.Validate( + "SecurePass123!", + CreatePolicy()); + + Assert.Empty(errors); + } + + [Theory] + [InlineData("", PasswordPolicyErrorCodes.Required)] + [InlineData("Ab1!", PasswordPolicyErrorCodes.TooShort)] + [InlineData("abcdefgh!", PasswordPolicyErrorCodes.DigitRequired)] + [InlineData("ABCDEFGH1!", PasswordPolicyErrorCodes.LowercaseRequired)] + [InlineData("abcdefgh1!", PasswordPolicyErrorCodes.UppercaseRequired)] + [InlineData("Abcdefgh12", PasswordPolicyErrorCodes.SpecialCharacterRequired)] + public void InvalidPassword_ReturnsExpectedCode(string password, string expectedCode) + { + var errors = UAuthPasswordClientValidator.Validate(password, CreatePolicy()); + + Assert.Contains(expectedCode, errors); + } + + [Fact] + public void TooLongPassword_ReturnsLengthViolation() + { + var errors = UAuthPasswordClientValidator.Validate( + new string('A', 129), + CreatePolicy()); + + Assert.Contains(PasswordPolicyErrorCodes.TooLong, errors); + } + + [Fact] + public void AbsoluteInputLimit_IsEnforced() + { + var errors = UAuthPasswordClientValidator.Validate( + new string('A', 257), + CreatePolicy()); + + Assert.Contains( + PasswordPolicyErrorCodes.InputTooLong, + errors); + } + + [Fact] + public void DisabledCharacterRequirements_AreNotEnforced() + { + var policy = CreatePolicy( + level: PasswordPolicyLevel.Basic, + minLength: 6, + requireLetter: false, + requireDigit: false, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false); + + var errors = UAuthPasswordClientValidator.Validate( + "abcdefgh", + policy); + + Assert.Empty(errors); + } + + [Fact] + public void ServerOnlyChecks_AreNotEvaluatedLocally() + { + var policy = CreatePolicy( + level: PasswordPolicyLevel.Standard, + requireUppercase: false, + requireLowercase: false, + requireSpecialCharacter: false, + checkCommonPasswords: true, + checkUserSimilarity: true, + checkPasswordHistory: true); + + var errors = UAuthPasswordClientValidator.Validate( + "qwerty123", + policy); + + Assert.Empty(errors); + } + + private static UAuthPasswordPolicyInfo CreatePolicy( + PasswordPolicyLevel level = PasswordPolicyLevel.Strict, + int minLength = 8, + int maxLength = 128, + bool requireLetter = true, + bool requireDigit = true, + bool requireUppercase = true, + bool requireLowercase = true, + bool requireSpecialCharacter = true, + bool checkCommonPasswords = false, + bool checkUserSimilarity = false, + bool checkPasswordHistory = false) + { + return new UAuthPasswordPolicyInfo + { + Level = level, + MinLength = minLength, + MaxLength = maxLength, + RequireLetter = requireLetter, + RequireDigit = requireDigit, + RequireUppercase = requireUppercase, + RequireLowercase = requireLowercase, + RequireSpecialCharacter = requireSpecialCharacter, + CheckCommonPasswords = checkCommonPasswords, + CheckUserSimilarity = checkUserSimilarity, + CheckPasswordHistory = checkPasswordHistory + }; + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs new file mode 100644 index 00000000..128150bf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordHistoryValidatorTests.cs @@ -0,0 +1,331 @@ +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordHistoryValidatorTests +{ + private static readonly TenantKey Tenant = TenantKey.Single; + + private static readonly DateTimeOffset Now = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static PasswordHash Hash(string password) => PasswordHash.Create("test", password); + + private static PasswordCredential Credential( + CredentialKey key, + UserKey user, + string password) => + PasswordCredential.Create( + id: key.Id, + tenant: key.Tenant, + userKey: user, + secretHash: Hash(password), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + + private static PasswordHistoryEntry History( + CredentialKey key, + UserKey user, + string password, + int minutes) => new() + { + Id = Guid.NewGuid(), + Tenant = key.Tenant, + UserKey = user, + CredentialKey = key, + SecretHash = Hash(password), + ReplacedAt = Now.AddMinutes(minutes) + }; + + private static TestFixture Create( + string currentPassword = "CurrentPassword123!") + { + var key = new CredentialKey(Tenant, Guid.NewGuid()); + var user = UserKey.New(); + + var credentialStore = new Mock(); + var credentialFactory = new Mock(); + var historyStore = new Mock(); + var historyFactory = new Mock(); + var hasher = new Mock(); + + credentialFactory + .Setup(x => x.Create(Tenant)) + .Returns(credentialStore.Object); + + credentialStore + .Setup(x => x.GetAsync( + key, It.IsAny())) + .ReturnsAsync(Credential( + key, user, currentPassword)); + + historyFactory + .Setup(x => x.Create(Tenant)) + .Returns(historyStore.Object); + + hasher + .Setup(x => x.Verify( + It.IsAny(), + It.IsAny())) + .Returns((PasswordHash hash, string password) => + hash == Hash(password)); + + var validator = new UAuthPasswordHistoryValidator( + credentialFactory.Object, + historyFactory.Object, + hasher.Object); + + return new TestFixture( + key, user, validator, + credentialStore, credentialFactory, + historyStore, historyFactory, hasher); + } + + private sealed record TestFixture( + CredentialKey Key, + UserKey User, + UAuthPasswordHistoryValidator Validator, + Mock CredentialStore, + Mock CredentialFactory, + Mock HistoryStore, + Mock HistoryFactory, + Mock Hasher); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task CurrentPassword_IsAlwaysRejected( + int rememberLast) + { + var f = Create(); + + var result = await f.Validator.ValidateAsync( + f.Key, "CurrentPassword123!", rememberLast, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeFalse(); + result.Violations.Should().ContainSingle( + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RememberLastZero_AllowsDifferentPassword_WithoutHistoryLookup() + { + var f = Create(); + + var result = await f.Validator.ValidateAsync( + f.Key, "DifferentPassword123!", 0, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeTrue(); + + f.HistoryFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HistoricalPassword_IsRejected() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 3, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Previous1!", 3), + History(f.Key, f.User, "Previous2!", 2), + History(f.Key, f.User, "Previous3!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "Previous2!", 3, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeFalse(); + result.Violations.Should().ContainSingle( + x => x.Code == PasswordPolicyErrorCodes.ReusedPassword); + } + + [Fact] + public async Task NewPassword_NotPresentInHistory_IsAllowed() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 2, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Previous1!", 2), + History(f.Key, f.User, "Previous2!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "EntirelyNewPassword123!", 2, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task EmptyHistory_AllowsDifferentPassword() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 5, It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 5, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task HistoryLookup_UsesRequestedLimit() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 7, It.IsAny())) + .ReturnsAsync(Array.Empty()); + + await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 7, ct: TestContext.Current.CancellationToken); + + f.HistoryStore.Verify( + x => x.GetRecentAsync( + f.Key, 7, It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(-1)] + [InlineData(11)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public async Task InvalidRememberLast_ThrowsBeforeStoreAccess( + int rememberLast) + { + var f = Create(); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", rememberLast); + + await act.Should() + .ThrowAsync(); + + f.CredentialFactory.VerifyNoOtherCalls(); + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task MissingCredential_Throws() + { + var f = Create(); + + f.CredentialStore + .Setup(x => x.GetAsync( + f.Key, It.IsAny())) + .ReturnsAsync((PasswordCredential?)null); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 3); + + await act.Should() + .ThrowAsync(); + + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task HistoryVerification_StopsAtFirstMatch() + { + var f = Create(); + + f.HistoryStore + .Setup(x => x.GetRecentAsync( + f.Key, 3, It.IsAny())) + .ReturnsAsync(new[] + { + History(f.Key, f.User, "Match123!", 3), + History(f.Key, f.User, "Older123!", 2), + History(f.Key, f.User, "Oldest123!", 1) + }); + + var result = await f.Validator.ValidateAsync( + f.Key, "Match123!", 3, ct: TestContext.Current.CancellationToken); + + result.Succeeded.Should().BeFalse(); + + // Current credential + first history entry only. + f.Hasher.Verify( + x => x.Verify( + It.IsAny(), + "Match123!"), + Times.Exactly(2)); + } + + [Fact] + public async Task CancelledToken_ThrowsBeforeStoreAccess() + { + var f = Create(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 3, cts.Token); + + await act.Should() + .ThrowAsync(); + + f.CredentialFactory.VerifyNoOtherCalls(); + f.HistoryFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CancellationToken_IsForwardedToStores() + { + var f = Create(); + + using var cts = new CancellationTokenSource(); + var ct = cts.Token; + + f.CredentialStore + .Setup(x => x.GetAsync(f.Key, ct)) + .ReturnsAsync(Credential( + f.Key, f.User, "CurrentPassword123!")); + + f.HistoryStore + .Setup(x => x.GetRecentAsync(f.Key, 2, ct)) + .ReturnsAsync(Array.Empty()); + + var result = await f.Validator.ValidateAsync( + f.Key, "NewPassword123!", 2, ct); + + result.Succeeded.Should().BeTrue(); + + f.CredentialStore.Verify( + x => x.GetAsync(f.Key, ct), + Times.Once); + + f.HistoryStore.Verify( + x => x.GetRecentAsync(f.Key, 2, ct), + Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs new file mode 100644 index 00000000..0657b3ad --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordStrengthPolicyTests.cs @@ -0,0 +1,124 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Credentials.Policies; + +public sealed class UAuthPasswordStrengthPolicyTests +{ + private readonly IPasswordStrengthPolicy _policy = new UAuthPasswordStrengthPolicy(new BuiltInCommonPasswordChecker(), new UAuthPasswordUserSimilarityChecker()); + + private static EffectivePasswordPolicyOptions Resolve(PasswordPolicyLevel level = PasswordPolicyLevel.Standard) + => PasswordPolicyOptionsResolver.Resolve(new PasswordPolicyOptions { Level = level }); + + private Task ValidateAsync(string password, PasswordPolicyLevel level = PasswordPolicyLevel.Standard, string? userName = null) + { + return _policy.ValidateAsync( + new PasswordPolicyContext + { + Password = password, + UserName = userName + }, + Resolve(level)); + } + + [Fact] + public async Task Standard_RequiresLetterAndDigit() + { + var result = await ValidateAsync("abcdefgh"); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.DigitRequired); + } + + [Fact] + public async Task Basic_AllowsPasswordWithoutDigits() + { + var result = await ValidateAsync( + "longpassphrase", + PasswordPolicyLevel.Basic); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Standard_RejectsCommonPassword() + { + var result = await ValidateAsync("password123"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.CommonPassword); + } + + [Fact] + public async Task Standard_RejectsUsernameInPassword() + { + var result = await ValidateAsync( + "JohnSmith123!", + userName: "johnsmith"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SimilarToUser); + } + + [Fact] + public async Task Strict_RejectsNormalizedUsername() + { + var result = await ValidateAsync( + "J0hn-Smith123!", + PasswordPolicyLevel.Strict, + "johnsmith"); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SimilarToUser); + } + + [Fact] + public async Task Strict_RequiresAllConfiguredClasses() + { + var result = await ValidateAsync( + "longpassword123", + PasswordPolicyLevel.Strict); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.UppercaseRequired); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.SpecialCharacterRequired); + } + + [Fact] + public async Task EmptyPassword_ReturnsRequiredViolation() + { + var result = await ValidateAsync(""); + + Assert.Contains( + result.Violations, + x => x.Code == PasswordPolicyErrorCodes.Required); + } + + [Fact] + public async Task ExcessiveInput_IsRejectedBeforeOtherChecks() + { + var result = await ValidateAsync(new string('a', 257)); + + Assert.Single(result.Violations); + Assert.Equal( + PasswordPolicyErrorCodes.InputTooLong, + result.Violations[0].Code); + } + + [Fact] + public async Task ValidStandardPassword_Succeeds() + { + var result = await ValidateAsync("BlueRiver4827!"); + + Assert.True(result.Succeeded); + Assert.Empty(result.Violations); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs new file mode 100644 index 00000000..54ea8477 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/UAuthPasswordUserSimilarityChecks.cs @@ -0,0 +1,271 @@ +ο»Ώusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Policies; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials; + +public sealed class UAuthPasswordUserSimilarityCheckerTests +{ + private readonly UAuthPasswordUserSimilarityChecker _checker = new(); + + private static PasswordPolicyContext Context(string password, string? userName = null, + params string[] identifiers) => new() + { + Password = password, + UserName = userName, + UserIdentifiers = identifiers + }; + + // ============================================================ + // BASIC + // ============================================================ + + [Theory] + [InlineData("Alice", "alice")] + [InlineData("ALICE", "alice")] + [InlineData("alice", "ALICE")] + public void Basic_RejectsExactUsernameIgnoringCase(string password, string userName) + { + Assert.True(_checker.IsSimilar(Context(password, userName), PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_RejectsExactIdentifier() + { + Assert.True(_checker.IsSimilar( + Context("alice@example.com", null, "alice@example.com"), + PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_AllowsUsernameEmbeddedInPassword() + { + Assert.False(_checker.IsSimilar( + Context("SecureAlice123!", "alice"), + PasswordPolicyLevel.Basic)); + } + + [Fact] + public void Basic_AllowsDifferentPassword() + { + Assert.False(_checker.IsSimilar( + Context("CompletelyDifferent123!", "alice"), + PasswordPolicyLevel.Basic)); + } + + // ============================================================ + // STANDARD + // ============================================================ + + [Theory] + [InlineData("alice", "SecureAlice123!")] + [InlineData("owner", "Owner-test-password-8!")] + [InlineData("member", "Member-test-password-8!")] + public void Standard_RejectsEmbeddedUsername( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsEmbeddedEmailLocalPart() + { + Assert.True(_checker.IsSimilar( + Context("SecureAlice123!", null, "alice@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsEmbeddedSecondaryIdentifier() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureMember123!", + "alice", + "alice@example.com", + "member@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Theory] + [InlineData("abc", "SecureAbc123!")] + [InlineData("ab", "SecureAb123!")] + [InlineData("a", "SecureA123!")] + public void Standard_DoesNotRejectShortEmbeddedUsername( + string userName, + string password) + { + Assert.False(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_RejectsFourCharacterUsername() + { + Assert.True(_checker.IsSimilar( + Context("SecureJohn123!", "john"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Standard_DoesNotApplyStrictNormalization() + { + Assert.False(_checker.IsSimilar( + Context("Secure4l1ce123!", "alice"), + PasswordPolicyLevel.Standard)); + } + + // ============================================================ + // STRICT + // ============================================================ + + [Theory] + [InlineData("alice", "Secure4l1ce123!")] + [InlineData("password", "SecureP@ssw0rd123!")] + [InlineData("master", "SecureM4$ter123!")] + [InlineData("test", "Secure7e$7123!")] + public void Strict_RejectsNormalizedUsername( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Strict)); + } + + [Theory] + [InlineData("john.doe", "SecureJohnDoe123!")] + [InlineData("john_doe", "SecureJohnDoe123!")] + [InlineData("john-doe", "SecureJohnDoe123!")] + public void Strict_IgnoresSeparators( + string userName, + string password) + { + Assert.True(_checker.IsSimilar( + Context(password, userName), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_NormalizesEmailLocalPart() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureJ0hnD0e123!", + null, + "john.doe@example.com"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_DoesNotMatchUnrelatedPassword() + { + Assert.False(_checker.IsSimilar( + Context("UnrelatedSecure123!", "alice"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Strict_DoesNotMatchShortNormalizedCandidate() + { + Assert.False(_checker.IsSimilar( + Context("Secure4l1123!", "ali"), + PasswordPolicyLevel.Strict)); + } + + // ============================================================ + // INPUT / IDENTIFIERS + // ============================================================ + + [Theory] + [InlineData(PasswordPolicyLevel.Basic)] + [InlineData(PasswordPolicyLevel.Standard)] + [InlineData(PasswordPolicyLevel.Strict)] + public void EmptyPassword_IsNotSimilar( + PasswordPolicyLevel level) + { + Assert.False(_checker.IsSimilar( + Context("", "alice"), + level)); + } + + [Fact] + public void MissingIdentifiers_ReturnsFalse() + { + Assert.False(_checker.IsSimilar( + Context("SecurePassword123!"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void WhitespaceIdentifiers_AreIgnored() + { + Assert.False(_checker.IsSimilar( + Context( + "SecurePassword123!", + " ", + " ", + "\t"), + PasswordPolicyLevel.Strict)); + } + + [Fact] + public void Identifiers_AreTrimmed() + { + Assert.True(_checker.IsSimilar( + Context("SecureAlice123!", " alice "), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void DuplicateIdentifiers_DoNotChangeResult() + { + Assert.True(_checker.IsSimilar( + Context( + "SecureAlice123!", + null, + "alice", + "ALICE", + "alice"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void IdentifierWithoutEmailLocalPart_IsHandled() + { + Assert.False(_checker.IsSimilar( + Context("SecureAlice123!", null, "@example.com"), + PasswordPolicyLevel.Standard)); + } + + [Fact] + public void Disabled_AlwaysReturnsFalse() + { + Assert.False(_checker.IsSimilar( + Context("alice", "alice"), + PasswordPolicyLevel.Disabled)); + } + + [Fact] + public void NullContext_Throws() + { + Assert.Throws(() => + _checker.IsSimilar( + null!, + PasswordPolicyLevel.Standard)); + } + + [Theory] + [InlineData(-1)] + [InlineData(999)] + public void InvalidLevel_Throws(int level) + { + Assert.Throws(() => + _checker.IsSimilar( + Context("SecurePassword123!", "alice"), + (PasswordPolicyLevel)level)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs index 6239983e..313366d1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs @@ -30,9 +30,9 @@ public async Task Add_And_Get_Should_Work() tenant, userKey); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(state.Id, result!.Id); @@ -51,13 +51,13 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); var updated = existing!.RegisterFailure( DateTimeOffset.UtcNow, @@ -65,13 +65,13 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() failureWindow: TimeSpan.FromMinutes(5), lockoutDuration: TimeSpan.FromMinutes(5)); - await store.UpdateAsync(updated, expectedVersion: 0); + await store.UpdateAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.SecurityVersion); Assert.Equal(1, result.FailedAttempts); @@ -89,10 +89,10 @@ public async Task Update_With_Wrong_Version_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); - await Assert.ThrowsAsync(() => store.UpdateAsync(updated, expectedVersion: 999)); + await Assert.ThrowsAsync(() => store.UpdateAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -109,21 +109,21 @@ public async Task RegisterSuccess_Should_Clear_Failures() await using (var db1 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); var updated = existing!.RegisterSuccess(); - await store.UpdateAsync(updated, expectedVersion: 1); + await store.UpdateAsync(updated, expectedVersion: 1, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.Equal(0, result!.FailedAttempts); Assert.Null(result.LockedUntil); @@ -142,7 +142,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db1 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); } DateTimeOffset now = DateTimeOffset.UtcNow; @@ -150,23 +150,23 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db2 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); var updated = existing!.BeginReset("hash", now, TimeSpan.FromMinutes(10)); - await store.UpdateAsync(updated, expectedVersion: 0); + await store.UpdateAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); var consumed = existing!.ConsumeReset(DateTimeOffset.UtcNow); - await store.UpdateAsync(consumed, expectedVersion: 1); + await store.UpdateAsync(consumed, expectedVersion: 1, ct: TestContext.Current.CancellationToken); } await using (var db4 = CreateDb(connection)) { var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.NotNull(result!.ResetConsumedAt); } } @@ -184,11 +184,11 @@ public async Task Delete_Should_Work() var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); - await store.AddAsync(state); + await store.AddAsync(state, ct: TestContext.Current.CancellationToken); - await store.DeleteAsync(userKey, AuthenticationSecurityScope.Account, null); + await store.DeleteAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -207,8 +207,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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); + await store1.AddAsync(state, ct: TestContext.Current.CancellationToken); + var result = await store2.GetAsync(userKey, AuthenticationSecurityScope.Account, null, ct: TestContext.Current.CancellationToken); Assert.Null(result); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs index b06267a9..b695ae8d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialAtomicityTests.cs @@ -49,7 +49,7 @@ public async Task ChangeSecret_WhenSessionRevocationFails_ShouldRollbackCredenti db, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } // Act: Change password, then simulate a session failure. @@ -60,7 +60,7 @@ public async Task ChangeSecret_WhenSessionRevocationFails_ShouldRollbackCredenti new TenantExecutionContext(tenant)); var existing = await store.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(existing); @@ -68,7 +68,7 @@ await Assert.ThrowsAsync( async () => { await using var transaction = - await db.Database.BeginTransactionAsync(); + await db.Database.BeginTransactionAsync(cancellationToken: TestContext.Current.CancellationToken); var updated = existing.ChangeSecret( hasher.Hash("new-password"), @@ -76,7 +76,7 @@ await Assert.ThrowsAsync( await store.SaveAsync( updated, - existing.Version); + existing.Version, ct: TestContext.Current.CancellationToken); // Simulate a failure during session revocation. throw new InvalidOperationException( @@ -92,7 +92,7 @@ await store.SaveAsync( new TenantExecutionContext(tenant)); var persisted = await store.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(persisted); @@ -135,7 +135,7 @@ public async Task AtomicExecutor_WhenOperationSucceeds_ShouldCommitCredential() db, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -173,7 +173,7 @@ await store.SaveAsync( ct); Assert.NotNull(db.Database.CurrentTransaction); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -183,7 +183,7 @@ await store.SaveAsync( new TenantExecutionContext(tenant)); var persisted = await store.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(persisted); Assert.True( @@ -217,7 +217,7 @@ public async Task AtomicExecutor_WhenOperationFails_ShouldRollbackCredential() db, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -258,7 +258,7 @@ await store.SaveAsync( throw new InvalidOperationException( "Simulated session revocation failure"); - })); + }, ct: TestContext.Current.CancellationToken)); } await using (var db = CreateDb(connection)) @@ -268,7 +268,7 @@ await store.SaveAsync( new TenantExecutionContext(tenant)); var persisted = await store.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(persisted); @@ -343,14 +343,14 @@ public async Task AtomicExecutor_WhenSessionOperationFails_ShouldRollbackBothSto db, new TenantExecutionContext(tenant)); - await credentialStore.AddAsync(credential); + await credentialStore.AddAsync(credential, ct: TestContext.Current.CancellationToken); await sessionStore.ExecuteAsync(async ct => { await sessionStore.CreateRootAsync(root, ct); await sessionStore.CreateChainAsync(chain, ct); await sessionStore.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } // Act: Change password and revoke session within one atomic operation. @@ -409,7 +409,7 @@ await sessionStore.RevokeAllChainsAsync( // Session operation succeeded, but the outer operation fails. throw new InvalidOperationException( "Simulated failure before outer commit"); - })); + }, ct: TestContext.Current.CancellationToken)); } // Assert: Both credential and session changes must be rolled back. @@ -426,7 +426,7 @@ await sessionStore.RevokeAllChainsAsync( new TenantExecutionContext(tenant)); var persisted = await credentialStore.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(persisted); @@ -437,7 +437,7 @@ await sessionStore.RevokeAllChainsAsync( Assert.Equal(0, persisted.Version); - var persistedSession = await sessionStore.GetSessionAsync(sessionId); + var persistedSession = await sessionStore.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.NotNull(persistedSession); Assert.False(persistedSession.IsRevoked); @@ -481,9 +481,9 @@ public async Task AtomicReset_WhenSessionRevocationFails_ShouldRollbackSecurityS db, new TenantExecutionContext(tenant)); - await credentialStore.AddAsync(credential); + await credentialStore.AddAsync(credential, ct: TestContext.Current.CancellationToken); - await securityStore.AddAsync(securityState); + await securityStore.AddAsync(securityState, ct: TestContext.Current.CancellationToken); var activeReset = securityState.BeginReset( resetTokenHash, @@ -492,7 +492,7 @@ public async Task AtomicReset_WhenSessionRevocationFails_ShouldRollbackSecurityS await securityStore.UpdateAsync( activeReset, - securityState.SecurityVersion); + securityState.SecurityVersion, ct: TestContext.Current.CancellationToken); } // Act: Consume reset token and change password, @@ -572,7 +572,7 @@ await sessionStore.RevokeAllChainsAsync( throw new InvalidOperationException( "Simulated failure before reset commit"); - })); + }, ct: TestContext.Current.CancellationToken)); } // Assert: Both security state and credential must be unchanged. @@ -589,7 +589,7 @@ await sessionStore.RevokeAllChainsAsync( new TenantExecutionContext(tenant)); var persistedCredential = await credentialStore.GetAsync( - new CredentialKey(tenant, credential.Id)); + new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(persistedCredential); @@ -608,7 +608,7 @@ await sessionStore.RevokeAllChainsAsync( var persistedState = await securityStore.GetAsync( user, AuthenticationSecurityScope.Account, - null); + null, ct: TestContext.Current.CancellationToken); Assert.NotNull(persistedState); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs index 094b9036..d4da21a4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs @@ -42,9 +42,9 @@ public async Task Add_And_Get_Should_Work() new CredentialMetadata(), DateTimeOffset.UtcNow); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); @@ -75,9 +75,9 @@ public async Task Exists_Should_Return_True_When_Exists() new CredentialMetadata(), DateTimeOffset.UtcNow); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); - var exists = await store.ExistsAsync(new CredentialKey(tenant, credential.Id)); + var exists = await store.ExistsAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.True(exists); } @@ -103,25 +103,25 @@ public async Task Save_Should_Increment_Version_And_Update_Hash() await using (var db1 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); - await store.SaveAsync(updated, expectedVersion: 0); + await store.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); Assert.True(hasher.Verify(result.SecretHash, "new")); @@ -149,18 +149,18 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => - store.SaveAsync(updated, expectedVersion: 999)); + store.SaveAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } } @@ -189,9 +189,9 @@ public async Task Should_Not_See_Data_From_Other_Tenant() new CredentialMetadata(), DateTimeOffset.UtcNow); - await store1.AddAsync(credential); + await store1.AddAsync(credential, ct: TestContext.Current.CancellationToken); - var result = await store2.GetAsync(new CredentialKey(tenant2, credential.Id)); + var result = await store2.GetAsync(new CredentialKey(tenant2, credential.Id), ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -218,15 +218,15 @@ public async Task Soft_Delete_Should_Work() new CredentialMetadata(), DateTimeOffset.UtcNow); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new CredentialKey(tenant, credential.Id), expectedVersion: 0, DeleteMode.Soft, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); @@ -253,24 +253,24 @@ public async Task Revoke_Should_Persist() await using (var db1 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); var revoked = existing!.Revoke(DateTimeOffset.UtcNow); - await store.SaveAsync(revoked, expectedVersion: 0); + await store.SaveAsync(revoked, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.True(result!.IsRevoked); } @@ -297,24 +297,24 @@ public async Task ChangeSecret_Should_Update_SecurityState() await using (var db1 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db1, new TenantExecutionContext(tenant)); - await store.AddAsync(credential); + await store.AddAsync(credential, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db2, new TenantExecutionContext(tenant)); - var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var existing = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); var updated = existing!.ChangeSecret(hasher.Hash("new"), DateTimeOffset.UtcNow); - await store.SaveAsync(updated, expectedVersion: 0); + await store.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store = new EfCorePasswordCredentialStore(db3, new TenantExecutionContext(tenant)); - var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id), ct: TestContext.Current.CancellationToken); Assert.True(hasher.Verify(result!.SecretHash, "new")); Assert.NotNull(result.UpdatedAt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs index 79d725a7..263a1d93 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs @@ -35,9 +35,9 @@ public async Task Add_And_Get_Should_Work() new[] { Permission.From("read"), Permission.From("write") }, DateTimeOffset.UtcNow); - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new RoleKey(tenant, role.Id)); + var result = await store.GetAsync(new RoleKey(tenant, role.Id), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("admin", result!.Name); @@ -56,9 +56,9 @@ public async Task Add_With_Duplicate_Name_Should_Throw() 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 store.AddAsync(role1, ct: TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => store.AddAsync(role2)); + await Assert.ThrowsAsync(() => store.AddAsync(role2, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -74,21 +74,21 @@ public async Task Save_Should_Increment_Version() var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); - await store.SaveAsync(updated, expectedVersion: 0); + await store.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store.GetAsync(new RoleKey(tenant, roleId)); + var result = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); Assert.Equal("admin2", result.Name); @@ -108,16 +108,16 @@ public async Task Save_With_Wrong_Version_Should_Throw() var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); - await Assert.ThrowsAsync(() => store.SaveAsync(updated, expectedVersion: 999)); + await Assert.ThrowsAsync(() => store.SaveAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } } @@ -138,17 +138,17 @@ public async Task Rename_To_Existing_Name_Should_Throw() 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 store.AddAsync(role1, ct: TestContext.Current.CancellationToken); + await store.AddAsync(role2, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var role = await store.GetAsync(new RoleKey(tenant, role2Id)); + var role = await store.GetAsync(new RoleKey(tenant, role2Id), ct: TestContext.Current.CancellationToken); var updated = role!.Rename("admin", DateTimeOffset.UtcNow); - await Assert.ThrowsAsync(() => store.SaveAsync(updated, 0)); + await Assert.ThrowsAsync(() => store.SaveAsync(updated, 0, ct: TestContext.Current.CancellationToken)); } } @@ -173,26 +173,26 @@ public async Task Save_Should_Replace_Permissions() roleId = role.Id; - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); var updated = existing!.SetPermissions( new[] { Permission.From(UAuthActions.Authorization.Roles.SetPermissionsAdmin) }, DateTimeOffset.UtcNow); - await store.SaveAsync(updated, 0); + await store.SaveAsync(updated, 0, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store.GetAsync(new RoleKey(tenant, roleId)); + var result = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); Assert.Single(result!.Permissions); Assert.Contains(result.Permissions, p => p.Value == UAuthActions.Authorization.Roles.SetPermissionsAdmin); @@ -212,19 +212,19 @@ public async Task Soft_Delete_Should_Work() var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; - await store.AddAsync(role); + await store.AddAsync(role, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store.DeleteAsync(new RoleKey(tenant, roleId), 0, DeleteMode.Soft, DateTimeOffset.UtcNow); + await store.DeleteAsync(new RoleKey(tenant, roleId), 0, DeleteMode.Soft, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store.GetAsync(new RoleKey(tenant, roleId)); + var result = await store.GetAsync(new RoleKey(tenant, roleId), ct: TestContext.Current.CancellationToken); Assert.NotNull(result!.DeletedAt); } } @@ -238,16 +238,16 @@ public async Task Query_Should_Filter_And_Page() var tenant = TenantKeys.Single; var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - 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)); + await store.AddAsync(Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow), ct: TestContext.Current.CancellationToken); + await store.AddAsync(Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow), ct: TestContext.Current.CancellationToken); + await store.AddAsync(Role.Create(null, tenant, "guest", null, DateTimeOffset.UtcNow), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync(new RoleQuery { Search = "us", PageNumber = 1, PageSize = 10 - }); + }, ct: TestContext.Current.CancellationToken); 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 index dd6d81da..7c0caceb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -64,9 +64,9 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); - var result = await store.GetSessionAsync(session.SessionId); + var result = await store.GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(session.SessionId, result!.SessionId); @@ -123,14 +123,14 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetSessionAsync(sessionId); + var result = await store.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result!.Device.DeviceId); @@ -200,14 +200,14 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetSessionAsync(sessionId); + var result = await store.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); @@ -269,12 +269,12 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); await store.ExecuteAsync(async ct => { revoked = await store.RevokeSessionAsync(sessionId, DateTimeOffset.UtcNow); - }); + }, ct: TestContext.Current.CancellationToken); Assert.True(revoked); } @@ -326,14 +326,14 @@ await store1.ExecuteAsync(async ct => await store1.CreateRootAsync(root, ct); await store1.CreateChainAsync(chain, ct); await store1.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store2 = new EfCoreSessionStore(db, new TenantExecutionContext(tenant2)); - var result = await store2.GetSessionAsync(sessionId); + var result = await store2.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -356,7 +356,7 @@ await Assert.ThrowsAsync(async () => await store.ExecuteAsync(async ct => { throw new InvalidOperationException("boom"); - }); + }, ct: TestContext.Current.CancellationToken); }); } } @@ -406,13 +406,13 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var sessions = await store.GetSessionsByChainAsync(chainId); + var sessions = await store.GetSessionsByChainAsync(chainId, ct: TestContext.Current.CancellationToken); Assert.Single(sessions); } } @@ -461,14 +461,14 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetSessionAsync(sessionId); + var result = await store.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); } @@ -498,7 +498,7 @@ await store.ExecuteAsync(async ct => // πŸ’₯ simulate failure throw new InvalidOperationException("boom"); - }); + }, ct: TestContext.Current.CancellationToken); }); } @@ -555,7 +555,7 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -565,13 +565,13 @@ await store.ExecuteAsync(async ct => await store.ExecuteAsync(async ct => { await store.RevokeChainCascadeAsync(chainId, DateTimeOffset.UtcNow, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var sessions = await store.GetSessionsByChainAsync(chainId); + var sessions = await store.GetSessionsByChainAsync(chainId, ct: TestContext.Current.CancellationToken); Assert.All(sessions, s => Assert.True(s.IsRevoked)); } @@ -612,14 +612,14 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.SetActiveSessionIdAsync(chainId, sessionId); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var active = await store.GetActiveSessionIdAsync(chainId); + var active = await store.GetActiveSessionIdAsync(chainId, ct: TestContext.Current.CancellationToken); Assert.Equal(sessionId, active); } @@ -687,7 +687,7 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -700,13 +700,13 @@ await store.ExecuteAsync(async ct => var updated = existing!.Revoke(DateTimeOffset.UtcNow); await store.SaveSessionAsync(updated, expectedVersion: 0, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetSessionAsync(sessionId); + var result = await store.GetSessionAsync(sessionId, ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); } @@ -758,7 +758,7 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); await store.CreateSessionAsync(session, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -773,7 +773,7 @@ await store.ExecuteAsync(async ct => var updated = existing!.Revoke(DateTimeOffset.UtcNow); await store.SaveSessionAsync(updated, expectedVersion: 999, ct); - }); + }, ct: TestContext.Current.CancellationToken); }); } } @@ -810,7 +810,7 @@ await store.ExecuteAsync(async ct => { await store.CreateRootAsync(root, ct); await store.CreateChainAsync(chain, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -823,13 +823,13 @@ await store.ExecuteAsync(async ct => var updated = existing!.Revoke(DateTimeOffset.UtcNow); await store.SaveChainAsync(updated, expectedVersion: 0, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetChainAsync(chainId); + var result = await store.GetChainAsync(chainId, ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); } @@ -861,7 +861,7 @@ public async Task SaveRoot_Should_Increment_Version() await store.ExecuteAsync(async ct => { await store.CreateRootAsync(root, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -884,7 +884,7 @@ await store.SaveRootAsync( updated, expectedVersion: existing.Version, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -894,7 +894,7 @@ await store.SaveRootAsync( new TenantExecutionContext(tenant)); var result = - await store.GetRootByIdAsync(rootId); + await store.GetRootByIdAsync(rootId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Version.Should().Be(1); @@ -913,7 +913,7 @@ public async Task ExecuteAsync_Should_Join_Existing_Transaction() new TenantExecutionContext(TenantKeys.Single)); await using var transaction = - await db.Database.BeginTransactionAsync(); + await db.Database.BeginTransactionAsync(cancellationToken: TestContext.Current.CancellationToken); var originalTransaction = db.Database.CurrentTransaction; @@ -924,12 +924,13 @@ await store.ExecuteAsync(async ct => db.Database.CurrentTransaction); await Task.CompletedTask; - }); + }, ct: TestContext.Current.CancellationToken); Assert.Same( originalTransaction, db.Database.CurrentTransaction); + // Cleanup must complete even when the test is cancelled. await transaction.RollbackAsync(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs index 7e36b9ff..612c7397 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs @@ -41,9 +41,9 @@ public async Task Store_And_Find_Should_Work() await store.ExecuteAsync(async ct => { await store.StoreAsync(token, ct); - }); + }, ct: TestContext.Current.CancellationToken); - var result = await store.FindByHashAsync("hash"); + var result = await store.FindByHashAsync("hash", ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("hash", result!.TokenHash); @@ -76,7 +76,7 @@ public async Task Revoke_Should_Set_RevokedAt() await store.ExecuteAsync(async ct => { await store.StoreAsync(token, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) @@ -86,13 +86,13 @@ await store.ExecuteAsync(async ct => await store.ExecuteAsync(async ct => { await store.RevokeAsync(tokenHash, DateTimeOffset.UtcNow, null, ct); - }); + }, ct: TestContext.Current.CancellationToken); } await using (var db = CreateDb(connection)) { var store = new EfCoreRefreshTokenStore(db, new TenantExecutionContext(tenant)); - var result = await store.FindByHashAsync(tokenHash); + var result = await store.FindByHashAsync(tokenHash, ct: TestContext.Current.CancellationToken); Assert.NotNull(result!.RevokedAt); } @@ -119,7 +119,7 @@ public async Task Store_Outside_Transaction_Should_Throw() DateTimeOffset.UtcNow.AddHours(1) ); - await Assert.ThrowsAsync(() => store.StoreAsync(token)); + await Assert.ThrowsAsync(() => store.StoreAsync(token, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -160,7 +160,7 @@ await store.StoreAsync( db, new TenantExecutionContext(tenant)); - var persisted = await store.FindByHashAsync(hash); + var persisted = await store.FindByHashAsync(hash, ct: TestContext.Current.CancellationToken); Assert.NotNull(persisted); } @@ -194,7 +194,7 @@ await store.StoreAsync( throw new InvalidOperationException( "Simulated outer transaction failure"); - })); + }, ct: TestContext.Current.CancellationToken)); }); } @@ -204,7 +204,7 @@ await store.StoreAsync( db, new TenantExecutionContext(tenant)); - var persisted = await store.FindByHashAsync(hash); + var persisted = await store.FindByHashAsync(hash, ct: TestContext.Current.CancellationToken); Assert.Null(persisted); } @@ -248,7 +248,7 @@ await store.StoreAsync( db, new TenantExecutionContext(tenant)); - Assert.NotNull(await store.FindByHashAsync(hash)); + Assert.NotNull(await store.FindByHashAsync(hash, ct: TestContext.Current.CancellationToken)); } } @@ -265,17 +265,17 @@ public async Task ExecuteAsync_AfterFailure_ShouldResetExecutionState() await Assert.ThrowsAsync( () => store.ExecuteAsync(_ => - throw new InvalidOperationException("Simulated failure"))); + throw new InvalidOperationException("Simulated failure"), ct: TestContext.Current.CancellationToken)); await store.ExecuteAsync(async ct => { await store.StoreAsync( CreateToken("after-failure", tenant), ct); - }); + }, ct: TestContext.Current.CancellationToken); Assert.NotNull( - await store.FindByHashAsync("after-failure")); + await store.FindByHashAsync("after-failure", ct: TestContext.Current.CancellationToken)); } [Fact] @@ -295,7 +295,7 @@ await Assert.ThrowsAsync( await store.ExecuteAsync( _ => Task.CompletedTask, ct); - })); + }, ct: TestContext.Current.CancellationToken)); // The failed nested call must not leave the store locked. await store.ExecuteAsync(async ct => @@ -303,10 +303,10 @@ await store.ExecuteAsync(async ct => await store.StoreAsync( CreateToken("after-nested-failure", tenant), ct); - }); + }, ct: TestContext.Current.CancellationToken); Assert.NotNull( - await store.FindByHashAsync("after-nested-failure")); + await store.FindByHashAsync("after-nested-failure", ct: TestContext.Current.CancellationToken)); } private static RefreshToken CreateToken(string hash, TenantKey tenant) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs index f06f5f60..aa6b4f65 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs @@ -37,8 +37,8 @@ public async Task Add_And_Get_Should_Work() DateTimeOffset.UtcNow, isPrimary: true); - await store.AddAsync(identifier); - var result = await store.GetAsync(identifier.Id); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); + var result = await store.GetAsync(identifier.Id, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(identifier.Id, result!.Id); @@ -63,8 +63,8 @@ public async Task Exists_Should_Return_True_When_Exists() DateTimeOffset.UtcNow, isPrimary: true); - await store.AddAsync(identifier); - var exists = await store.ExistsAsync(identifier.Id); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); + var exists = await store.ExistsAsync(identifier.Id, ct: TestContext.Current.CancellationToken); Assert.True(exists); } @@ -90,7 +90,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() DateTimeOffset.UtcNow, isPrimary: true); - await store1.AddAsync(identifier); + await store1.AddAsync(identifier, ct: TestContext.Current.CancellationToken); await using var db2 = CreateDb(connection); var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); @@ -98,7 +98,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() var updated = identifier.SetPrimary(DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => - store2.SaveAsync(updated, expectedVersion: 999)); + store2.SaveAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -122,21 +122,21 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(identifier); + await store1.AddAsync(identifier, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(identifier.Id); + var existing = await store2.GetAsync(identifier.Id, ct: TestContext.Current.CancellationToken); var updated = existing!.SetPrimary(DateTimeOffset.UtcNow); - await store2.SaveAsync(updated, expectedVersion: 0); + await store2.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserIdentifierStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store3.GetAsync(identifier.Id); + var result = await store3.GetAsync(identifier.Id, ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); } } @@ -163,8 +163,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() DateTimeOffset.UtcNow, isPrimary: true); - await store1.AddAsync(identifier); - var result = await store2.GetAsync(identifier.Id); + await store1.AddAsync(identifier, ct: TestContext.Current.CancellationToken); + var result = await store2.GetAsync(identifier.Id, ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -188,9 +188,9 @@ public async Task Soft_Delete_Should_Work() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); + await store.DeleteAsync(identifier.Id, 0, DeleteMode.Soft, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); + var result = await store.GetAsync(identifier.Id, ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs index 9c97641c..8c5f0d0a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs @@ -33,9 +33,9 @@ public async Task Add_And_Get_Should_Work() userKey, DateTimeOffset.UtcNow); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey)); + var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(userKey, result!.UserKey); @@ -58,8 +58,8 @@ public async Task Exists_Should_Return_True_When_Exists() userKey, DateTimeOffset.UtcNow); - await store.AddAsync(lifecycle); - var exists = await store.ExistsAsync(new UserLifecycleKey(tenant, userKey)); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); + var exists = await store.ExistsAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); Assert.True(exists); } @@ -80,21 +80,21 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(lifecycle); + await store1.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); - await store2.SaveAsync(updated, expectedVersion: 0); + await store2.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); + var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); Assert.Equal(UserStatus.Suspended, result.Status); @@ -117,17 +117,17 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(lifecycle); + await store1.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); await Assert.ThrowsAsync(() => - store2.SaveAsync(updated, expectedVersion: 999)); + store2.SaveAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } } @@ -150,9 +150,9 @@ public async Task Should_Not_See_Data_From_Other_Tenant() userKey, DateTimeOffset.UtcNow); - await store1.AddAsync(lifecycle); + await store1.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); - var result = await store2.GetAsync(new UserLifecycleKey(tenant2, userKey)); + var result = await store2.GetAsync(new UserLifecycleKey(tenant2, userKey), ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -173,15 +173,15 @@ public async Task Soft_Delete_Should_Work() userKey, DateTimeOffset.UtcNow); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserLifecycleKey(tenant, userKey), expectedVersion: 0, DeleteMode.Soft, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey)); + var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); @@ -203,24 +203,24 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(lifecycle); + await store1.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); var deleted = existing!.MarkDeleted(DateTimeOffset.UtcNow); - await store2.SaveAsync(deleted, expectedVersion: 0); + await store2.SaveAsync(deleted, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); + var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey), ct: TestContext.Current.CancellationToken); 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 index 7da74d8d..0e8cba83 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -40,8 +40,8 @@ public async Task Add_And_Get_Should_Work() lastName: "last" ); - await store.AddAsync(profile); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(userKey, result!.UserKey); @@ -69,8 +69,8 @@ public async Task Exists_Should_Return_True_When_Exists() lastName: "last" ); - await store.AddAsync(profile); - var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); Assert.True(exists); } @@ -97,21 +97,21 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(profile); + await store1.AddAsync(profile, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); - await store2.SaveAsync(updated, expectedVersion: 0); + await store2.SaveAsync(updated, expectedVersion: 0, ct: TestContext.Current.CancellationToken); } await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserProfileStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var result = await store3.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var result = await store3.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); Assert.Equal(1, result!.Version); Assert.Equal("new", result.DisplayName); @@ -140,17 +140,17 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - await store1.AddAsync(profile); + await store1.AddAsync(profile, ct: TestContext.Current.CancellationToken); } await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => - store2.SaveAsync(updated, expectedVersion: 999)); + store2.SaveAsync(updated, expectedVersion: 999, ct: TestContext.Current.CancellationToken)); } } @@ -179,8 +179,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() lastName: "last" ); - await store1.AddAsync(profile); - var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey, ProfileKey.Default)); + await store1.AddAsync(profile, ct: TestContext.Current.CancellationToken); + var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); Assert.Null(result); } @@ -207,15 +207,15 @@ public async Task Soft_Delete_Should_Work() lastName: "last" ); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey(tenant, userKey, ProfileKey.Default), expectedVersion: 0, DeleteMode.Soft, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); @@ -248,11 +248,11 @@ public async Task Same_User_Can_Have_Multiple_Profiles() DateTimeOffset.UtcNow, displayName: "business"); - await store.AddAsync(defaultProfile); - await store.AddAsync(businessProfile); + await store.AddAsync(defaultProfile, ct: TestContext.Current.CancellationToken); + await store.AddAsync(businessProfile, ct: TestContext.Current.CancellationToken); - var p1 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); - var p2 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + var p1 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); + var p2 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null)), ct: TestContext.Current.CancellationToken); Assert.NotNull(p1); Assert.NotNull(p2); @@ -276,7 +276,7 @@ await store.AddAsync(UserProfile.Create( userKey, ProfileKey.Default, DateTimeOffset.UtcNow, - displayName: "default")); + displayName: "default"), ct: TestContext.Current.CancellationToken); await store.AddAsync(UserProfile.Create( Guid.NewGuid(), @@ -284,10 +284,10 @@ await store.AddAsync(UserProfile.Create( userKey, ProfileKey.Parse("business", null), DateTimeOffset.UtcNow, - displayName: "business")); + displayName: "business"), ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( - new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null)), ct: TestContext.Current.CancellationToken); Assert.Equal("business", result!.DisplayName); } @@ -309,7 +309,7 @@ await store.AddAsync(UserProfile.Create( userKey, ProfileKey.Default, DateTimeOffset.UtcNow, - displayName: "default")); + displayName: "default"), ct: TestContext.Current.CancellationToken); await store.AddAsync(UserProfile.Create( Guid.NewGuid(), @@ -317,11 +317,11 @@ await store.AddAsync(UserProfile.Create( userKey, ProfileKey.Parse("business", null), DateTimeOffset.UtcNow, - displayName: "business")); + displayName: "business"), ct: TestContext.Current.CancellationToken); var results = await store.GetByUsersAsync( new[] { userKey }, - ProfileKey.Default); + ProfileKey.Default, ct: TestContext.Current.CancellationToken); Assert.Single(results); Assert.Equal(ProfileKey.Default, results[0].ProfileKey); @@ -352,10 +352,10 @@ public async Task Should_Not_Allow_Duplicate_ProfileKey_For_Same_User() ProfileKey.Default, DateTimeOffset.UtcNow); - await store.AddAsync(profile1); + await store.AddAsync(profile1, ct: TestContext.Current.CancellationToken); await Assert.ThrowsAsync(() => - store.AddAsync(profile2)); + store.AddAsync(profile2, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -383,20 +383,20 @@ public async Task Delete_Should_Not_Affect_Other_Profiles() ProfileKey.Parse("business", null), DateTimeOffset.UtcNow); - await store.AddAsync(defaultProfile); - await store.AddAsync(businessProfile); + await store.AddAsync(defaultProfile, ct: TestContext.Current.CancellationToken); + await store.AddAsync(businessProfile, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey(tenant, userKey, ProfileKey.Default), 0, DeleteMode.Soft, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); var defaultResult = await store.GetAsync( - new UserProfileKey(tenant, userKey, ProfileKey.Default)); + new UserProfileKey(tenant, userKey, ProfileKey.Default), ct: TestContext.Current.CancellationToken); var businessResult = await store.GetAsync( - new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null)), ct: TestContext.Current.CancellationToken); Assert.NotNull(defaultResult!.DeletedAt); Assert.Null(businessResult!.DeletedAt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs index 5d9b1264..212052d6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs @@ -27,8 +27,8 @@ public async Task Assign_And_GetAssignments_Should_Work() var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); - await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); - var result = await store.GetAssignmentsAsync(userKey); + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); + var result = await store.GetAssignmentsAsync(userKey, ct: TestContext.Current.CancellationToken); Assert.Single(result); Assert.Equal(roleId, result.First().RoleId); @@ -46,9 +46,9 @@ public async Task Assign_Duplicate_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); - await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow)); + await Assert.ThrowsAsync(() => store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken)); } [Fact] @@ -63,9 +63,9 @@ public async Task Remove_Should_Work() 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); + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); + await store.RemoveAsync(userKey, roleId, ct: TestContext.Current.CancellationToken); + var result = await store.GetAssignmentsAsync(userKey, ct: TestContext.Current.CancellationToken); Assert.Empty(result); } @@ -82,7 +82,7 @@ public async Task Remove_NonExisting_Should_Not_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); - await store.RemoveAsync(userKey, roleId); // should not throw + await store.RemoveAsync(userKey, roleId, ct: TestContext.Current.CancellationToken); // should not throw } [Fact] @@ -96,10 +96,10 @@ public async Task CountAssignments_Should_Return_Correct_Count() var roleId = RoleId.New(); - await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow); - await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow); + await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - var count = await store.CountAssignmentsAsync(roleId); + var count = await store.CountAssignmentsAsync(roleId, ct: TestContext.Current.CancellationToken); Assert.Equal(2, count); } @@ -118,12 +118,12 @@ public async Task RemoveAssignmentsByRole_Should_Remove_All() 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.AssignAsync(user1, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); + await store.AssignAsync(user2, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - await store.RemoveAssignmentsByRoleAsync(roleId); + await store.RemoveAssignmentsByRoleAsync(roleId, ct: TestContext.Current.CancellationToken); - var count = await store.CountAssignmentsAsync(roleId); + var count = await store.CountAssignmentsAsync(roleId, ct: TestContext.Current.CancellationToken); Assert.Equal(0, count); } @@ -143,9 +143,9 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); - await store1.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + await store1.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - var result = await store2.GetAssignmentsAsync(userKey); + var result = await store2.GetAssignmentsAsync(userKey, ct: TestContext.Current.CancellationToken); Assert.Empty(result); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index c09a2df2..8333db93 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -72,7 +72,7 @@ public Task LogoutAllUserDevicesAsync(UserKey userKey) throw new NotImplementedException(); } - public Task LogoutAsync() + public Task LogoutAsync(bool navigation = true) { throw new NotImplementedException(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index d20659d9..2615f72a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -1,6 +1,7 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -19,6 +20,7 @@ public static AccessContext WithAction(string action) targetUserKey: null, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -43,6 +45,7 @@ public static AccessContext ForUser( targetUserKey: userKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } @@ -68,6 +71,7 @@ public static AccessContext ForTargetUser( targetUserKey: targetUserKey, resourceTenant: t, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance ); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs index 73385ead..53bf5919 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/InMemory/InMemoryVersionedStoreTests.cs @@ -15,9 +15,9 @@ public async Task AddAsync_WithoutAtomicContext_ShouldPersistEntity() var entity = TestEntity.Create("entity-1", "initial"); - await store.AddAsync(entity); + await store.AddAsync(entity, ct: TestContext.Current.CancellationToken); - var stored = await store.GetAsync("entity-1"); + var stored = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); stored.Should().NotBeNull(); stored!.Value.Should().Be("initial"); @@ -35,15 +35,15 @@ public async Task AddAsync_WithAtomicContext_WhenRolledBack_ShouldRemoveEntity() var store = new TestStore(accessor); await store.AddAsync( - TestEntity.Create("entity-1", "initial")); + TestEntity.Create("entity-1", "initial"), ct: TestContext.Current.CancellationToken); - (await store.ExistsAsync("entity-1")) + (await store.ExistsAsync("entity-1", ct: TestContext.Current.CancellationToken)) .Should() .BeTrue(); atomic.Rollback(); - (await store.ExistsAsync("entity-1")) + (await store.ExistsAsync("entity-1", ct: TestContext.Current.CancellationToken)) .Should() .BeFalse(); } @@ -55,27 +55,27 @@ public async Task SaveAsync_WithAtomicContext_WhenRolledBack_ShouldRestorePrevio var store = new TestStore(accessor); await store.AddAsync( - TestEntity.Create("entity-1", "initial")); + TestEntity.Create("entity-1", "initial"), ct: TestContext.Current.CancellationToken); var atomic = new InMemoryAtomicContext(); accessor.Current = atomic; - var entity = await store.GetAsync("entity-1"); + var entity = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); entity!.Value = "updated"; await store.SaveAsync( entity, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); - var updated = await store.GetAsync("entity-1"); + var updated = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); updated!.Value.Should().Be("updated"); updated.Version.Should().Be(1); atomic.Rollback(); - var restored = await store.GetAsync("entity-1"); + var restored = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); restored.Should().NotBeNull(); restored!.Value.Should().Be("initial"); @@ -89,7 +89,7 @@ public async Task DeleteAsync_Hard_WithAtomicContext_WhenRolledBack_ShouldRestor var store = new TestStore(accessor); await store.AddAsync( - TestEntity.Create("entity-1", "initial")); + TestEntity.Create("entity-1", "initial"), ct: TestContext.Current.CancellationToken); var atomic = new InMemoryAtomicContext(); accessor.Current = atomic; @@ -98,15 +98,15 @@ await store.DeleteAsync( "entity-1", expectedVersion: 0, DeleteMode.Hard, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); - (await store.ExistsAsync("entity-1")) + (await store.ExistsAsync("entity-1", ct: TestContext.Current.CancellationToken)) .Should() .BeFalse(); atomic.Rollback(); - var restored = await store.GetAsync("entity-1"); + var restored = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); restored.Should().NotBeNull(); restored!.Value.Should().Be("initial"); @@ -121,7 +121,7 @@ public async Task DeleteAsync_Soft_WithAtomicContext_WhenRolledBack_ShouldRestor var store = new TestStore(accessor); await store.AddAsync( - TestEntity.Create("entity-1", "initial")); + TestEntity.Create("entity-1", "initial"), ct: TestContext.Current.CancellationToken); var atomic = new InMemoryAtomicContext(); accessor.Current = atomic; @@ -136,9 +136,9 @@ await store.DeleteAsync( "entity-1", expectedVersion: 0, DeleteMode.Soft, - deletedAt); + deletedAt, ct: TestContext.Current.CancellationToken); - var deleted = await store.GetAsync("entity-1"); + var deleted = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); deleted.Should().NotBeNull(); deleted!.IsDeleted.Should().BeTrue(); @@ -147,7 +147,7 @@ await store.DeleteAsync( atomic.Rollback(); - var restored = await store.GetAsync("entity-1"); + var restored = await store.GetAsync("entity-1", ct: TestContext.Current.CancellationToken); restored.Should().NotBeNull(); restored!.IsDeleted.Should().BeFalse(); @@ -162,41 +162,41 @@ public async Task MultipleOperations_WhenRolledBack_ShouldRestoreOriginalState() var store = new TestStore(accessor); await store.AddAsync( - TestEntity.Create("existing", "original")); + TestEntity.Create("existing", "original"), ct: TestContext.Current.CancellationToken); var atomic = new InMemoryAtomicContext(); accessor.Current = atomic; // ADD await store.AddAsync( - TestEntity.Create("new", "created")); + TestEntity.Create("new", "created"), ct: TestContext.Current.CancellationToken); // UPDATE - var existing = await store.GetAsync("existing"); + var existing = await store.GetAsync("existing", ct: TestContext.Current.CancellationToken); existing!.Value = "updated"; await store.SaveAsync( existing, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); // DELETE newly-created entity await store.DeleteAsync( "new", expectedVersion: 0, DeleteMode.Hard, - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, ct: TestContext.Current.CancellationToken); atomic.Rollback(); var restoredExisting = - await store.GetAsync("existing"); + await store.GetAsync("existing", ct: TestContext.Current.CancellationToken); restoredExisting.Should().NotBeNull(); restoredExisting!.Value.Should().Be("original"); restoredExisting.Version.Should().Be(0); - (await store.ExistsAsync("new")) + (await store.ExistsAsync("new", ct: TestContext.Current.CancellationToken)) .Should() .BeFalse(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs index 5ce023e3..cfb317dd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Policies; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -43,7 +44,7 @@ public async Task Decide_UnauthenticatedActor_ShouldDeny() var context = TestAccessContext.WithAction("users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("unauthenticated"); @@ -58,7 +59,7 @@ public async Task Decide_AuthenticatedActor_ShouldAllow() UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); } @@ -84,7 +85,7 @@ public async Task Decide_SameTenant_ShouldAllow() var context = TestAccessContext.ForUser(UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); } @@ -108,9 +109,10 @@ public async Task Decide_CrossTenant_ShouldDeny() targetUserKey: null, resourceTenant: resourceTenant, action: "users.get.admin", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("cross_tenant_access_denied"); @@ -151,7 +153,7 @@ public async Task Decide_UnauthenticatedActor_ShouldDeny() var context = TestAccessContext.WithAction("users.update.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("unauthenticated"); @@ -169,7 +171,7 @@ public async Task Decide_WhenActorIsTarget_ShouldAllow() userKey, "users.update.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.DenyReason.Should().BeNull(); @@ -189,7 +191,7 @@ public async Task Decide_WhenActorIsNotTarget_ShouldDeny() target, "users.update.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("not_self"); @@ -230,7 +232,7 @@ public async Task Decide_NormalActor_ShouldDeny() var context = TestAccessContext.WithAction("users.repair.system"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("system_actor_required"); @@ -252,9 +254,10 @@ public async Task Decide_SystemActor_ShouldAllow() targetUserKey: null, resourceTenant: TenantKey.Single, action: "users.repair.system", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.DenyReason.Should().BeNull(); @@ -341,7 +344,7 @@ public async Task Decide_UnauthenticatedActor_ShouldDeny() var context = TestAccessContext.WithAction("users.update.admin"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("unauthenticated"); @@ -360,7 +363,7 @@ public async Task Decide_AdminModifyingOwnAccount_ShouldDeny() userKey, "users.update.admin"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should() @@ -380,7 +383,7 @@ public async Task Decide_AdminDeletingOwnAccount_ShouldDeny() userKey, "users.delete.admin"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should() @@ -401,7 +404,7 @@ public async Task Decide_AdminModifyingDifferentUser_ShouldAllow() target, "users.update.admin"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.DenyReason.Should().BeNull(); @@ -508,7 +511,7 @@ public async Task Decide_ShouldDelegateToInnerPolicy() var context = TestAccessContext.WithAction("users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("inner_denied"); @@ -531,7 +534,7 @@ public async Task Decide_ShouldReturnInnerAllowDecision() var context = TestAccessContext.WithAction("users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); inner.DecideCallCount.Should().Be(1); @@ -616,7 +619,7 @@ public async Task Decide_WhenPermissionsAttributeIsMissing_ShouldDeny() var context = TestAccessContext.WithAction("users.update.admin"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("missing_permission"); @@ -629,7 +632,7 @@ public async Task Decide_WhenPermissionsAttributeHasWrongType_ShouldDeny() var context = TestAccessContext.WithAction("users.update.admin") .WithAttribute(UAuthConstants.Access.Permissions, "invalid-permissions"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("missing_permission"); @@ -651,7 +654,7 @@ public async Task Decide_WhenPermissionAllowsAction_ShouldAllow() UAuthConstants.Access.Permissions, permissions); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); } @@ -671,7 +674,7 @@ public async Task Decide_WhenPermissionDoesNotAllowAction_ShouldDeny() UAuthConstants.Access.Permissions, permissions); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("missing_permission"); @@ -692,7 +695,7 @@ public async Task Decide_WhenPermissionAllowsExactAction_ShouldAllow() UAuthConstants.Access.Permissions, permissions); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.DenyReason.Should().BeNull(); @@ -763,6 +766,7 @@ public void AppliesTo_SystemActor_ShouldReturnFalse() targetUserKey: null, resourceTenant: TenantKey.System, action: "users.get.admin", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); sut.AppliesTo(context).Should().BeFalse(); @@ -777,7 +781,7 @@ public async Task Decide_WhenActorIsMissing_ShouldDeny() var context = TestAccessContext.WithAction("users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("missing_actor"); @@ -797,7 +801,7 @@ public async Task Decide_WhenRuntimeStateIsMissing_ShouldDeny() var context = TestAccessContext.ForUser(UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("user_not_found"); @@ -818,7 +822,7 @@ public async Task Decide_WhenUserDoesNotExist_ShouldDeny() var context = TestAccessContext.ForUser(UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("user_not_found"); @@ -841,7 +845,7 @@ public async Task Decide_WhenUserIsDeleted_ShouldDeny() UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("user_not_found"); @@ -864,7 +868,7 @@ public async Task Decide_WhenUserIsInactive_ShouldDeny() UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeFalse(); result.DenyReason.Should().Be("user_not_active"); @@ -887,7 +891,7 @@ public async Task Decide_WhenUserIsActive_ShouldAllow() UserKey.New(), "users.get.self"); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); result.IsAllowed.Should().BeTrue(); result.DenyReason.Should().BeNull(); @@ -911,7 +915,7 @@ public async Task Decide_ShouldQueryRuntimeUsingActorTenantAndUserKey() var sut = new RequireActiveUserPolicy(runtime); var context = TestAccessContext.ForUser(userKey, "users.get.self", tenant); - var result = await sut.DecideAsync(context); + var result = await sut.DecideAsync(context, ct: TestContext.Current.CancellationToken); runtime.LastTenant.Should().Be(tenant); runtime.LastUserKey.Should().Be(userKey); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs index 2b184062..afde4863 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs @@ -1,10 +1,11 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.ResourceApi; -using AwesomeAssertions; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Http; using System.Net; using System.Net.Http.Json; @@ -32,7 +33,7 @@ public async Task ValidateSessionAsync_ShouldPostSessionAndTenantToValidationEnd var context = CreateValidationContext(); - await sut.ValidateSessionAsync(context); + await sut.ValidateSessionAsync(context, ct: TestContext.Current.CancellationToken); handler.Method.Should().Be(HttpMethod.Post); @@ -74,7 +75,7 @@ public async Task ValidateSessionAsync_WhenIncomingRequestContainsCookie_ShouldF httpContext); await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); handler.Cookie.Should() .Be("uauth_session=session-cookie; other=value"); @@ -91,7 +92,7 @@ public async Task ValidateSessionAsync_WhenIncomingRequestDoesNotContainCookie_S var sut = CreateSut(handler); await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); handler.Cookie.Should().BeNull(); } @@ -116,7 +117,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsNonSuccess_ShouldReturnNotF var context = CreateValidationContext(); var result = - await sut.ValidateSessionAsync(context); + await sut.ValidateSessionAsync(context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.NotFound); @@ -141,7 +142,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsJsonNull_ShouldReturnNotFou var context = CreateValidationContext(); var result = - await sut.ValidateSessionAsync(context); + await sut.ValidateSessionAsync(context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.NotFound); @@ -171,7 +172,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsInvalidResult_ShouldMapResu var result = await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -249,7 +250,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidSnapshot_ShouldReturnA var sut = CreateSut(handler); var result = await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.State.Should().Be(SessionState.Active); @@ -294,7 +295,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutIdentity_Should var sut = CreateSut(handler); var result = await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); } @@ -322,7 +323,7 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutSnapshot_Should var sut = CreateSut(handler); var result = await sut.ValidateSessionAsync( - CreateValidationContext()); + CreateValidationContext(), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); } @@ -371,6 +372,51 @@ public void ToDomain_WhenIsValidTrueButStateIsRevoked_ShouldFailClosed() } + [Fact] + public async Task ValidateSessionAsync_WhenDeviceIdExists_ShouldForwardDeviceId() + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(HttpStatusCode.Unauthorized) + }; + + var sut = CreateSut(handler); + + var context = CreateValidationContext() with + { + Device = TestDevice.Default() + }; + + await sut.ValidateSessionAsync( + context, + ct: TestContext.Current.CancellationToken); + + handler.DeviceId.Should().Be(context.Device.DeviceId!.Value.Value); + } + + [Fact] + public async Task ValidateSessionAsync_WhenDeviceIdIsMissing_ShouldNotForwardDeviceId() + { + var handler = new TestHttpMessageHandler + { + Response = CreateFailureResponse(HttpStatusCode.Unauthorized) + }; + + var sut = CreateSut(handler); + + var context = CreateValidationContext() with + { + Device = DeviceContext.Anonymous() + }; + + await sut.ValidateSessionAsync( + context, + ct: TestContext.Current.CancellationToken); + + handler.DeviceId.Should().BeNull(); + } + + private static RemoteSessionValidator CreateSut(TestHttpMessageHandler handler, HttpContext? httpContext = null) { var httpClient = new HttpClient(handler) @@ -418,11 +464,13 @@ private sealed class TestHttpMessageHandler : HttpMessageHandler public Uri? RequestUri { get; private set; } public string? Body { get; private set; } public string? Cookie { get; private set; } + public string? DeviceId { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Method = request.Method; RequestUri = request.RequestUri; + DeviceId = request.Headers.TryGetValues("X-UDID", out var values) ? values.SingleOrDefault() : null; if (request.Content is not null) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/UAuthCsrfPolicyEvaluatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/UAuthCsrfPolicyEvaluatorTests.cs new file mode 100644 index 00000000..c78b8d27 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/UAuthCsrfPolicyEvaluatorTests.cs @@ -0,0 +1,73 @@ +ο»Ώusing CodeBeam.UltimateAuth.Server.Security; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Security; + +public sealed class UAuthCsrfPolicyEvaluatorTests +{ + [Theory] + [InlineData(UAuthCsrfOperation.Login)] + [InlineData(UAuthCsrfOperation.PkceCompletion)] + [InlineData(UAuthCsrfOperation.AuthenticatedMutation)] + public void CookieIssuance_RequiresProtection(UAuthCsrfOperation operation) + { + var result = UAuthCsrfPolicyEvaluator.RequiresProtection( + operation, + canIssueCookie: true, + canAcceptCookie: false); + + Assert.True(result); + } + + [Theory] + [InlineData(UAuthCsrfOperation.Login)] + [InlineData(UAuthCsrfOperation.PkceCompletion)] + [InlineData(UAuthCsrfOperation.AuthenticatedMutation)] + public void CookieAcceptance_RequiresProtection(UAuthCsrfOperation operation) + { + var result = UAuthCsrfPolicyEvaluator.RequiresProtection( + operation, + canIssueCookie: false, + canAcceptCookie: true); + + Assert.True(result); + } + + [Theory] + [InlineData(UAuthCsrfOperation.Login)] + [InlineData(UAuthCsrfOperation.PkceCompletion)] + [InlineData(UAuthCsrfOperation.AuthenticatedMutation)] + public void HeaderOnlyTransport_DoesNotRequireProtection(UAuthCsrfOperation operation) + { + var result = UAuthCsrfPolicyEvaluator.RequiresProtection( + operation, + canIssueCookie: false, + canAcceptCookie: false); + + Assert.False(result); + } + + [Theory] + [InlineData(UAuthCsrfOperation.Login)] + [InlineData(UAuthCsrfOperation.PkceCompletion)] + [InlineData(UAuthCsrfOperation.AuthenticatedMutation)] + public void BothCookieCapabilities_RequireProtection(UAuthCsrfOperation operation) + { + var result = UAuthCsrfPolicyEvaluator.RequiresProtection( + operation, + canIssueCookie: true, + canAcceptCookie: true); + + Assert.True(result); + } + + [Fact] + public void UnknownOperation_FailsClosed() + { + var result = UAuthCsrfPolicyEvaluator.RequiresProtection( + (UAuthCsrfOperation)999, + canIssueCookie: false, + canAcceptCookie: false); + + Assert.True(result); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs index 4f627fb1..38023698 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs @@ -89,7 +89,7 @@ public async Task CreateAsync_ShouldResolveTrustedActorRoles() var context = await sut.CreateAsync( flow, "users.get.self", - "users"); + "users", ct: TestContext.Current.CancellationToken); // Only active roles are exposed. context.ActorRoles.Should().HaveCount(2); @@ -161,7 +161,7 @@ public async Task CreateAsync_ShouldIgnoreUntrustedRolesForAnonymousActor() flow, "users.get.anonymous", "users", - attributes: attributes); + attributes: attributes, ct: TestContext.Current.CancellationToken); // Anonymous actors must never receive trusted roles. context.ActorRoles.Should().BeEmpty(); @@ -203,7 +203,7 @@ public async Task CreateForUserTargetsAsync_ShouldNormalizeAndPreserveTargets() flow, "users.getbykeys.admin", "users", - new[] { first, second, first }); + new[] { first, second, first }, ct: TestContext.Current.CancellationToken); context.TargetUserKeys.Should().HaveCount(2); context.TargetUserKeys.Should().ContainInOrder(first, second); @@ -236,7 +236,7 @@ public async Task CreateForUserTargetsAsync_ShouldSnapshotInputCollection() flow, "users.getbykeys.admin", "users", - input); + input, ct: TestContext.Current.CancellationToken); input.Add(second); @@ -263,7 +263,7 @@ public async Task CreateForUserTargetsAsync_WithAttribute_ShouldPreserveTargets( flow, "users.getbykeys.admin", "users", - new[] { first, second }); + new[] { first, second }, ct: TestContext.Current.CancellationToken); var updated = context.WithAttribute("source", "test"); @@ -289,7 +289,7 @@ public async Task CreateAsync_SingleTargetContext_ShouldHaveEmptyTargetUserKeys( var context = await sut.CreateAsync( flow, "users.query.admin", - "users"); + "users", ct: TestContext.Current.CancellationToken); context.TargetUserKeys.Should().BeEmpty(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs index b7ffd6e5..37160702 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs @@ -30,7 +30,7 @@ public async Task Allowed_pipeline_should_execute_command_once() await pipeline.Orchestrator.ExecuteAsync( TestAccessContext.WithAction("users.read"), - command); + command, ct: TestContext.Current.CancellationToken); executions.Should().Be(1); } @@ -49,7 +49,7 @@ public async Task Generic_pipeline_should_return_command_result() var result = await pipeline.Orchestrator.ExecuteAsync( TestAccessContext.WithAction("users.read"), - command); + command, ct: TestContext.Current.CancellationToken); result.Should().Be("expected-result"); executions.Should().Be(1); @@ -169,7 +169,7 @@ await pipeline.Orchestrator.ExecuteAsync( { executed = true; return Task.CompletedTask; - })); + }), ct: TestContext.Current.CancellationToken); executed.Should().BeTrue(); @@ -317,7 +317,7 @@ public async Task Runtime_reauthentication_should_be_reported_by_authority() var decision = await authority.DecideAsync( TestAccessContext.WithAction("users.update"), - new[] { first.Object, second.Object }); + new[] { first.Object, second.Object }, ct: TestContext.Current.CancellationToken); decision.RequiresReauthentication.Should().BeTrue(); decision.IsAllowed.Should().BeFalse(); @@ -340,7 +340,7 @@ public async Task Runtime_denial_should_take_priority_over_earlier_reauthenticat var decision = await authority.DecideAsync( TestAccessContext.WithAction("users.update"), - new[] { reauth.Object, deny.Object }); + new[] { reauth.Object, deny.Object }, ct: TestContext.Current.CancellationToken); decision.IsDenied.Should().BeTrue(); decision.DenyReason.Should().Be("explicit_denial"); @@ -397,7 +397,7 @@ public async Task Authenticated_actor_should_receive_compiled_permissions() await pipeline.Orchestrator.ExecuteAsync( context, - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured.Should().NotBeSameAs(context); @@ -454,7 +454,7 @@ public async Task Authority_should_receive_same_enriched_context_as_policy_provi await pipeline.Orchestrator.ExecuteAsync( context, - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); providerContext.Should().NotBeNull(); authorityContext.Should().BeSameAs(providerContext); @@ -476,7 +476,7 @@ public async Task Anonymous_actor_should_skip_permission_store() await pipeline.Orchestrator.ExecuteAsync( context, - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); pipeline.PermissionStore.Verify( s => s.GetPermissionsAsync( @@ -615,7 +615,7 @@ public async Task Authority_should_allow_when_no_policies_exist() var decision = await authority.DecideAsync( TestAccessContext.WithAction("users.delete"), - Array.Empty()); + Array.Empty(), ct: TestContext.Current.CancellationToken); decision.IsAllowed.Should().BeTrue(); } @@ -629,7 +629,7 @@ public async Task Authority_should_treat_null_global_collections_as_empty() var decision = await authority.DecideAsync( TestAccessContext.WithAction("users.read"), - Array.Empty()); + Array.Empty(), ct: TestContext.Current.CancellationToken); decision.IsAllowed.Should().BeTrue(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs index d6c0eaba..fe29c217 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs @@ -16,7 +16,7 @@ public async Task DecideAsync_WithLegacyInvariant_ShouldWork() new IAuthorityInvariant[] { invariant }, Array.Empty()); - var result = await authority.DecideAsync(CreateContext()); + var result = await authority.DecideAsync(CreateContext(), ct: TestContext.Current.CancellationToken); result.Decision.Should().Be(AuthorizationDecision.Deny); result.Reason.Should().Be("legacy_invariant_denied"); @@ -32,7 +32,7 @@ public async Task DecideAsync_ShouldAwaitAsyncPolicy() Array.Empty(), new IAuthorityPolicy[] { policy }); - var evaluation = authority.DecideAsync(CreateContext()); + var evaluation = authority.DecideAsync(CreateContext(), ct: TestContext.Current.CancellationToken); // Ensure the policy has started evaluating. await policy.Started; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs index 2e7664b9..af993204 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthenticationSecurityManagerTests.cs @@ -60,7 +60,7 @@ public async Task GetOrCreateAccountAsync_WhenStateExists_ShouldReturnExistingSt var result = await _sut.GetOrCreateAccountAsync( _tenant, - _userKey); + _userKey, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); @@ -94,7 +94,7 @@ public async Task GetOrCreateAccountAsync_WhenStateDoesNotExist_ShouldCreateStat var result = await _sut.GetOrCreateAccountAsync( _tenant, - _userKey); + _userKey, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.Tenant.Should().Be(_tenant); @@ -139,7 +139,7 @@ public async Task GetOrCreateAccountAsync_WhenConcurrentCreationOccurs_ShouldRet var result = await _sut.GetOrCreateAccountAsync( _tenant, - _userKey); + _userKey, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); @@ -203,7 +203,7 @@ public async Task GetOrCreateFactorAsync_WhenStateExists_ShouldReturnExistingSta var result = await _sut.GetOrCreateFactorAsync( _tenant, _userKey, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); @@ -238,7 +238,7 @@ public async Task GetOrCreateFactorAsync_WhenStateDoesNotExist_ShouldCreateCorre var result = await _sut.GetOrCreateFactorAsync( _tenant, _userKey, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); result.Tenant.Should().Be(_tenant); result.UserKey.Should().Be(_userKey); @@ -287,7 +287,7 @@ public async Task GetOrCreateFactorAsync_WhenConcurrentCreationOccurs_ShouldRetu var result = await _sut.GetOrCreateFactorAsync( _tenant, _userKey, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); } @@ -315,7 +315,7 @@ public async Task MutateAccountAsync_WhenMutationReturnsSameInstance_ShouldNotUp var result = await _sut.MutateAccountAsync( _tenant, _userKey, - state => state); + state => state, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); @@ -355,7 +355,7 @@ public async Task MutateAccountAsync_WhenUpdateSucceeds_ShouldUseCurrentSecurity var result = await _sut.MutateAccountAsync( _tenant, _userKey, - _ => updated); + _ => updated, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(updated); @@ -425,7 +425,7 @@ public async Task MutateAccountAsync_WhenUpdateConflicts_ShouldReloadAndReapplyM { mutationCall++; return CreateMutatedState(state); - }); + }, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -506,7 +506,7 @@ public async Task MutateFactorAsync_WhenMutationReturnsSameInstance_ShouldNotUpd _tenant, _userKey, CredentialType.Password, - state => state); + state => state, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(existing); @@ -568,7 +568,7 @@ public async Task MutateFactorAsync_WhenUpdateConflicts_ShouldReloadAndReapplyMu { mutationCalls++; return CreateMutatedState(state); - }); + }, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -601,7 +601,7 @@ public async Task UpdateAsync_ShouldResolveStoreUsingUpdatedStateTenant() await _sut.UpdateAsync( state, - expectedVersion: 7); + expectedVersion: 7, ct: TestContext.Current.CancellationToken); _storeFactory.Verify( x => x.Create(otherTenant), @@ -632,7 +632,7 @@ await _sut.DeleteAsync( otherTenant, _userKey, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); _storeFactory.Verify( x => x.Create(otherTenant), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs index 98ad80d4..807bc3e1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubCredentialResolverTests.cs @@ -27,7 +27,7 @@ public async Task ResolveAsync_WhenArtifactDoesNotExist_ShouldReturnNull() It.IsAny())) .ReturnsAsync((AuthArtifact?)null); - var result = await fixture.Sut.ResolveAsync(hubSessionId); + var result = await fixture.Sut.ResolveAsync(hubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -47,7 +47,7 @@ public async Task ResolveAsync_WhenArtifactIsNotHubFlow_ShouldReturnNull() It.IsAny())) .ReturnsAsync(artifact); - var result = await fixture.Sut.ResolveAsync(hubSessionId); + var result = await fixture.Sut.ResolveAsync(hubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -63,7 +63,7 @@ public async Task ResolveAsync_WhenFlowIsCompleted_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -81,7 +81,7 @@ public async Task ResolveAsync_WhenAuthorizationCodeIsMissing_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -100,7 +100,7 @@ public async Task ResolveAsync_WhenAuthorizationCodeIsEmpty_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -118,7 +118,7 @@ public async Task ResolveAsync_WhenCodeVerifierIsMissing_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -137,7 +137,7 @@ public async Task ResolveAsync_WhenCodeVerifierIsEmpty_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -153,7 +153,7 @@ public async Task ResolveAsync_WhenCredentialsAreValid_ShouldReturnCredentials() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -171,7 +171,7 @@ public async Task ResolveAsync_ShouldReadArtifactUsingHubSessionId() SetupArtifact(fixture, artifact); await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.GetAsync( @@ -218,7 +218,7 @@ public async Task ResolveAsync_WhenFlowIsExpired_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -234,7 +234,7 @@ public async Task ResolveAsync_WhenFlowExpiresExactlyNow_ShouldReturnNull() SetupArtifact(fixture, artifact); var result = await fixture.Sut.ResolveAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs index a32b437e..f891464d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowReaderTests.cs @@ -34,7 +34,7 @@ public async Task GetStateAsync_WhenArtifactDoesNotExist_ShouldReturnNull() It.IsAny())) .ReturnsAsync((AuthArtifact?)null); - var result = await fixture.Sut.GetStateAsync(hubSessionId); + var result = await fixture.Sut.GetStateAsync(hubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -54,7 +54,7 @@ public async Task GetStateAsync_WhenArtifactIsNotHubFlow_ShouldReturnNull() new TestAuthArtifact( Now.AddMinutes(5))); - var result = await fixture.Sut.GetStateAsync(hubSessionId); + var result = await fixture.Sut.GetStateAsync(hubSessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -74,7 +74,7 @@ public async Task GetStateAsync_WhenFlowIsActive_ShouldReturnActiveState() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -99,7 +99,7 @@ public async Task GetStateAsync_WhenFlowIsExpired_ShouldReturnExpiredState() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -120,7 +120,7 @@ public async Task GetStateAsync_WhenFlowExpiresExactlyNow_ShouldReturnExpiredSta SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -147,7 +147,7 @@ public async Task GetStateAsync_WhenFlowIsCompleted_ShouldReturnCompletedState() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -170,7 +170,7 @@ public async Task GetStateAsync_WhenFlowIsExpiredAndCompleted_ShouldPreserveBoth SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -197,7 +197,7 @@ public async Task GetStateAsync_ShouldMapHubFlowState() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -234,7 +234,7 @@ public async Task GetStateAsync_ShouldMapErrorAndAttemptCount() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -260,7 +260,7 @@ public async Task GetStateAsync_ShouldPreserveMultipleAttempts() SetupArtifact(fixture, artifact); var result = await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -282,7 +282,7 @@ public async Task GetStateAsync_ShouldReadArtifactUsingHubSessionId() SetupArtifact(fixture, artifact); await fixture.Sut.GetStateAsync( - artifact.HubSessionId); + artifact.HubSessionId, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.GetAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs index 41fe0264..334623f6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/HubFlowServiceTests.cs @@ -53,7 +53,7 @@ public async Task BeginLoginAsync_ShouldCreateAndStoreHubFlowArtifact() }) .Returns(Task.CompletedTask); - var result = await fixture.Sut.BeginLoginAsync(request); + var result = await fixture.Sut.BeginLoginAsync(request, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.HubSessionId.Should().NotBeNullOrWhiteSpace(); @@ -96,7 +96,7 @@ public async Task BeginLoginAsync_ShouldStoreAuthorizationCodeAndCodeVerifier() .Returns(Task.CompletedTask); await fixture.Sut.BeginLoginAsync( - CreateBeginRequest()); + CreateBeginRequest(), ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -129,7 +129,7 @@ public async Task BeginLoginAsync_ShouldSetExpirationFromClockAndConfiguredLifet .Returns(Task.CompletedTask); await fixture.Sut.BeginLoginAsync( - CreateBeginRequest()); + CreateBeginRequest(), ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured!.ExpiresAt.Should().Be(Now.Add(FlowLifetime)); @@ -145,7 +145,7 @@ public async Task BeginLoginAsync_WhenPreviousHubSessionIdExists_ShouldConsumePr var request = CreateBeginRequest( previousHubSessionId: previousId); - await fixture.Sut.BeginLoginAsync(request); + await fixture.Sut.BeginLoginAsync(request, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -167,7 +167,7 @@ public async Task BeginLoginAsync_WhenPreviousHubSessionIdIsMissing_ShouldNotCon var request = CreateBeginRequest( previousHubSessionId: previousHubSessionId); - await fixture.Sut.BeginLoginAsync(request); + await fixture.Sut.BeginLoginAsync(request, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -234,7 +234,7 @@ public async Task ContinuePkceAsync_ShouldReplacePkceCredentials() await fixture.Sut.ContinuePkceAsync( artifact.HubSessionId.Value, newAuthorizationCode, - newCodeVerifier); + newCodeVerifier, ct: TestContext.Current.CancellationToken); artifact.Payload .GetRequired("authorization_code") @@ -264,7 +264,7 @@ public async Task ContinuePkceAsync_ShouldClearExistingError() await fixture.Sut.ContinuePkceAsync( artifact.HubSessionId.Value, "new-code", - "new-verifier"); + "new-verifier", ct: TestContext.Current.CancellationToken); artifact.Error.Should().BeNull(); @@ -285,7 +285,7 @@ public async Task ContinuePkceAsync_ShouldStoreUpdatedArtifact() await fixture.Sut.ContinuePkceAsync( artifact.HubSessionId.Value, "new-code", - "new-verifier"); + "new-verifier", ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.StoreAsync( @@ -527,7 +527,7 @@ public async Task ConsumeAsync_WhenHubSessionIdIsEmpty_ShouldDoNothing( { var fixture = CreateFixture(); - await fixture.Sut.ConsumeAsync(hubSessionId); + await fixture.Sut.ConsumeAsync(hubSessionId, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -543,7 +543,7 @@ public async Task ConsumeAsync_WhenHubSessionIdExists_ShouldConsumeArtifact() const string hubSessionId = "hub-session"; - await fixture.Sut.ConsumeAsync(hubSessionId); + await fixture.Sut.ConsumeAsync(hubSessionId, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 3402774d..058f45fe 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -29,7 +29,7 @@ public async Task Successful_login_should_return_success_result() { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -46,7 +46,7 @@ public async Task Successful_login_should_create_session() { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); result.SessionId.Should().NotBeNull(); } @@ -67,11 +67,11 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state?.FailedAttempts.Should().Be(1); } @@ -91,18 +91,18 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); await orchestrator.LoginAsync(flow, new LoginRequest { Identifier = "user", Secret = "user", // valid password - }); + }, ct: TestContext.Current.CancellationToken); var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state?.FailedAttempts.Should().Be(0); } @@ -118,7 +118,7 @@ public async Task Invalid_password_should_fail_login() { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); } @@ -135,7 +135,7 @@ public async Task Non_existent_user_should_fail_login_gracefully() { Identifier = "ghost", Secret = "whatever", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); } @@ -156,11 +156,11 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state!.IsLocked(DateTimeOffset.UtcNow).Should().BeTrue(); } @@ -181,14 +181,14 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); var result = await orchestrator.LoginAsync(flow, new LoginRequest { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); } @@ -209,19 +209,19 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); await orchestrator.LoginAsync(flow, new LoginRequest { Identifier = "user", - }); + }, ct: TestContext.Current.CancellationToken); - var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); } @@ -243,12 +243,12 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); } var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state?.IsLocked(DateTimeOffset.UtcNow).Should().BeFalse(); state?.FailedAttempts.Should().Be(5); @@ -271,11 +271,11 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); var factory = runtime.Services.GetRequiredService(); var store = factory.Create(TenantKeys.Single); - var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); var lockedUntil = state1!.LockedUntil; @@ -284,9 +284,9 @@ await orchestrator.LoginAsync(flow, { Identifier = "user", Secret = "wrong", - }); + }, ct: TestContext.Current.CancellationToken); - var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password, ct: TestContext.Current.CancellationToken); state2?.LockedUntil.Should().Be(lockedUntil); } @@ -311,7 +311,7 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured!.UserKey.Should().Be(TestUsers.User); @@ -338,7 +338,7 @@ public async Task Login_success_should_trigger_OnAnyEvent() { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); count.Should().BeGreaterThan(0); } @@ -358,7 +358,7 @@ public async Task Event_handler_exception_should_not_break_login_flow() { Identifier = "user", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -395,13 +395,13 @@ public async Task Login_With_Execution_Context_Should_Create_Session_For_Overrid result.SessionId.Should().NotBeNull(); var sessionStore = sessionStoreFactory.Create(TenantKeys.Single); - var session = await sessionStore.GetSessionAsync(result.SessionId!.Value); + var session = await sessionStore.GetSessionAsync(result.SessionId!.Value, ct: TestContext.Current.CancellationToken); 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!); + var chain = await chainStore.GetChainByDeviceAsync(TestUsers.User, (DeviceId)overriddenDevice.DeviceId!, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); chain!.Device.DeviceId.Should().Be(overriddenDevice.DeviceId); @@ -429,7 +429,7 @@ public async Task Internal_Login_Should_Respect_LoginExecutionOptions() Identifier = "user", Secret = "user" }, - options); + options, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); } @@ -438,10 +438,10 @@ public async Task Internal_Login_Should_Respect_LoginExecutionOptions() public async Task Concurrent_First_Logins_FromDifferentDevices_ShouldCreateSingleRootAndDifferentChains() { var runtime = new TestAuthRuntime(); - var user = await runtime.CreateLoginUserAsync(); + var user = await runtime.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var storeFactory = runtime.Services.GetRequiredService(); var store = storeFactory.Create(TenantKeys.Single); - var rootBefore = await store.GetActiveRootByUserAsync(user.UserKey); + var rootBefore = await store.GetActiveRootByUserAsync(user.UserKey, ct: TestContext.Current.CancellationToken); rootBefore.Should().BeNull(); @@ -472,8 +472,8 @@ public async Task Concurrent_First_Logins_FromDifferentDevices_ShouldCreateSingl Secret = user.Secret }; - var taskA = runtime.LoginAsync(flowA, executionA, requestA); - var taskB = runtime.LoginAsync(flowB, executionB, requestB); + var taskA = runtime.LoginAsync(flowA, executionA, requestA, ct: TestContext.Current.CancellationToken); + var taskB = runtime.LoginAsync(flowB, executionB, requestB, ct: TestContext.Current.CancellationToken); var results = await Task.WhenAll(taskA, taskB); @@ -486,11 +486,11 @@ public async Task Concurrent_First_Logins_FromDifferentDevices_ShouldCreateSingl var sessionA = await store.GetSessionAsync( - results[0].SessionId!.Value); + results[0].SessionId!.Value, ct: TestContext.Current.CancellationToken); var sessionB = await store.GetSessionAsync( - results[1].SessionId!.Value); + results[1].SessionId!.Value, ct: TestContext.Current.CancellationToken); sessionA.Should().NotBeNull(); sessionB.Should().NotBeNull(); @@ -500,11 +500,11 @@ await store.GetSessionAsync( var chainA = await store.GetChainAsync( - sessionA.ChainId); + sessionA.ChainId, ct: TestContext.Current.CancellationToken); var chainB = await store.GetChainAsync( - sessionB.ChainId); + sessionB.ChainId, ct: TestContext.Current.CancellationToken); chainA.Should().NotBeNull(); chainB.Should().NotBeNull(); @@ -514,7 +514,7 @@ await store.GetChainAsync( var activeRoot = await store.GetActiveRootByUserAsync( - user.UserKey); + user.UserKey, ct: TestContext.Current.CancellationToken); activeRoot.Should().NotBeNull(); @@ -534,7 +534,7 @@ public async Task Concurrent_First_Logins_FromSameDevice_ShouldCreateSingleRootA // Fresh user: no Root, Chain or Session exists. // var user = - await runtime.CreateLoginUserAsync(); + await runtime.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); var storeFactory = runtime.Services.GetRequiredService(); @@ -544,7 +544,7 @@ public async Task Concurrent_First_Logins_FromSameDevice_ShouldCreateSingleRootA var rootBefore = await store.GetActiveRootByUserAsync( - user.UserKey); + user.UserKey, ct: TestContext.Current.CancellationToken); rootBefore.Should().BeNull(); @@ -595,13 +595,13 @@ await store.GetActiveRootByUserAsync( runtime.LoginAsync( flowA, executionA, - requestA); + requestA, ct: TestContext.Current.CancellationToken); var taskB = runtime.LoginAsync( flowB, executionB, - requestB); + requestB, ct: TestContext.Current.CancellationToken); var results = await Task.WhenAll( @@ -616,11 +616,11 @@ await Task.WhenAll( var sessionA = await store.GetSessionAsync( - results[0].SessionId!.Value); + results[0].SessionId!.Value, ct: TestContext.Current.CancellationToken); var sessionB = await store.GetSessionAsync( - results[1].SessionId!.Value); + results[1].SessionId!.Value, ct: TestContext.Current.CancellationToken); sessionA.Should().NotBeNull(); sessionB.Should().NotBeNull(); @@ -633,7 +633,7 @@ await store.GetSessionAsync( var chain = await store.GetChainAsync( - sessionA.ChainId); + sessionA.ChainId, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); @@ -642,7 +642,7 @@ await store.GetChainAsync( // var activeRoot = await store.GetActiveRootByUserAsync( - user.UserKey); + user.UserKey, ct: TestContext.Current.CancellationToken); activeRoot.Should().NotBeNull(); @@ -700,7 +700,7 @@ public async Task Unsupported_credential_type_should_not_affect_password_lockout var state = await store.GetAsync( TestUsers.User, AuthenticationSecurityScope.Factor, - CredentialType.Password); + CredentialType.Password, ct: TestContext.Current.CancellationToken); (state?.FailedAttempts ?? 0).Should().Be(0); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs new file mode 100644 index 00000000..971e5f53 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PasswordPolicyEndpointHandlerTests.cs @@ -0,0 +1,173 @@ +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class PasswordPolicyEndpointHandlerTests +{ + [Fact] + public async Task GetAsync_WhenAllowed_ReturnsEffectivePolicyAndNoStore() + { + var f = new Fixture(); + + f.AllowExecution(); + + var result = await f.Sut.GetAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + + var info = ok.Value!; + + info.Level.Should().Be( + f.ExpectedPolicy.Level); + info.MinLength.Should().Be( + f.ExpectedPolicy.MinLength); + info.MaxLength.Should().Be( + f.ExpectedPolicy.MaxLength); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().Be("no-store"); + + f.VerifyAccessContextCreated(); + f.Orchestrator.Verify( + x => x.ExecuteAsync( + f.AccessContext, + It.IsAny>(), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task GetAsync_WhenAccessDenied_DoesNotExecutePolicyCommand() + { + var f = new Fixture(); + + f.Orchestrator + .Setup(x => x.ExecuteAsync( + f.AccessContext, + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException( + "Access denied by policy")); + + var act = () => f.Sut.GetAsync(f.HttpContext); + + await act.Should() + .ThrowAsync(); + + f.VerifyAccessContextCreated(); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().BeEmpty(); + } + + [Fact] + public async Task GetAsync_WhenRequestCancelled_PropagatesCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var f = new Fixture(cts.Token); + + f.AllowExecution(); + + var act = () => f.Sut.GetAsync(f.HttpContext); + + await act.Should() + .ThrowAsync(); + + f.VerifyAccessContextCreated(); + + f.HttpContext.Response.Headers.CacheControl + .ToString().Should().BeEmpty(); + } + + private sealed class Fixture + { + public DefaultHttpContext HttpContext { get; } = new(); + + public AuthFlowContext Flow { get; } = + AuthFlowTestFactory.New(isAuthenticated: false); + + public Mock FlowAccessor { get; } = new(); + public Mock ContextFactory { get; } = new(); + public Mock Orchestrator { get; } = new(); + + public AccessContext AccessContext { get; } + + public UAuthPasswordPolicyInfo ExpectedPolicy { get; } + + public PasswordPolicyEndpointHandler Sut { get; } + + public Fixture(CancellationToken ct = default) + { + HttpContext.RequestAborted = ct; + + AccessContext = TestAccessContext.WithAction( + UAuthActions.Credentials.GetPasswordPolicyAnonymous); + + ExpectedPolicy = + PasswordPolicyOptionsResolver.Resolve( + Flow.Options.Password).ToInfo(); + + FlowAccessor + .SetupGet(x => x.Current) + .Returns(Flow); + + ContextFactory + .Setup(x => x.CreateAsync( + Flow, + UAuthActions.Credentials.GetPasswordPolicyAnonymous, + "credentials", + null, + null, + ct)) + .ReturnsAsync(AccessContext); + + Sut = new PasswordPolicyEndpointHandler( + FlowAccessor.Object, + ContextFactory.Object, + Orchestrator.Object); + } + + public void AllowExecution() + { + Orchestrator + .Setup(x => x.ExecuteAsync( + AccessContext, + It.IsAny>(), + HttpContext.RequestAborted)) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + } + + public void VerifyAccessContextCreated() + { + ContextFactory.Verify( + x => x.CreateAsync( + Flow, + UAuthActions.Credentials.GetPasswordPolicyAnonymous, + "credentials", + null, + null, + HttpContext.RequestAborted), + Times.Once); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs index 845460e6..c8921eb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs @@ -103,7 +103,7 @@ public async Task AuthorizeAsync_ShouldStorePkceAuthorizationArtifact() .Returns(Task.CompletedTask); var result = - await fixture.Sut.AuthorizeAsync(command); + await fixture.Sut.AuthorizeAsync(command, ct: TestContext.Current.CancellationToken); capturedKey.Should().NotBeNull(); capturedArtifact.Should().NotBeNull(); @@ -182,7 +182,7 @@ public async Task CompleteAsync_WhenAuthorizationArtifactDoesNotExist_ShouldRetu var result = await fixture.Sut.CompleteAsync( auth, - request); + request, ct: TestContext.Current.CancellationToken); result.InvalidPkce.Should().BeTrue(); @@ -224,7 +224,7 @@ public async Task CompleteAsync_ShouldConsumeAuthorizationCodeUsingRequestCode() await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - request); + request, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -257,7 +257,7 @@ public async Task CompleteAsync_WhenPkceValidationFails_ShouldNotAttemptLogin() var result = await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - CreateCompleteRequest()); + CreateCompleteRequest(), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.FailureReason @@ -309,7 +309,7 @@ public async Task CompleteAsync_WhenValidationSucceeds_ShouldPassCredentialsToLo await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - request); + request, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -357,7 +357,7 @@ public async Task CompleteAsync_WhenValidationSucceeds_ShouldUseArtifactExecutio await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - CreateCompleteRequest()); + CreateCompleteRequest(), ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -423,7 +423,7 @@ public async Task RefreshAsync_WhenOldAuthorizationCodeExists_ShouldConsumeIt() var hub = CreateHubArtifact( authorizationCode: "old-code"); - await fixture.Sut.RefreshAsync(hub); + await fixture.Sut.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -446,7 +446,7 @@ public async Task RefreshAsync_WhenOldAuthorizationCodeDoesNotExist_ShouldNotCon var hub = CreateHubArtifact( authorizationCode: oldCode); - await fixture.Sut.RefreshAsync(hub); + await fixture.Sut.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ConsumeAsync( @@ -483,7 +483,7 @@ public async Task RefreshAsync_ShouldCreateNewPkceArtifactWithHubContext() .Returns(Task.CompletedTask); var result = - await fixture.Sut.RefreshAsync(hub); + await fixture.Sut.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -536,7 +536,7 @@ public async Task RefreshAsync_ReturnedVerifier_ShouldMatchStoredS256Challenge() .Returns(Task.CompletedTask); var result = - await fixture.Sut.RefreshAsync(hub); + await fixture.Sut.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -600,7 +600,7 @@ public async Task CompleteAsync_WhenLoginFails_ShouldMapLoginFailure() var result = await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - CreateCompleteRequest()); + CreateCompleteRequest(), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); @@ -635,7 +635,7 @@ public async Task CompleteAsync_WhenLoginSucceeds_ShouldReturnLoginResult() var result = await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), - CreateCompleteRequest()); + CreateCompleteRequest(), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.FailureReason.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs index c5119182..4be8a36e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs @@ -61,11 +61,11 @@ public async Task Refresh_Should_Generate_New_AuthorizationCode() var (artifact, _) = TestPkceFactory.Create(); - await store.StoreAsync(artifact.AuthorizationCode, artifact); + await store.StoreAsync(artifact.AuthorizationCode, artifact, cancellationToken: TestContext.Current.CancellationToken); var hub = TestHubFactory.Create(artifact); - var refreshed = await pkceService.RefreshAsync(hub); + var refreshed = await pkceService.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); refreshed.AuthorizationCode.Should().NotBe(artifact.AuthorizationCode.Value); } @@ -82,13 +82,13 @@ public async Task Refresh_Should_Store_New_PkceArtifact() var (artifact, _) = TestPkceFactory.Create(); - await store.StoreAsync(artifact.AuthorizationCode, artifact); + await store.StoreAsync(artifact.AuthorizationCode, artifact, cancellationToken: TestContext.Current.CancellationToken); var hub = TestHubFactory.Create(artifact); - var refreshed = await pkceService.RefreshAsync(hub); + var refreshed = await pkceService.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); - var stored = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode)); + var stored = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode), cancellationToken: TestContext.Current.CancellationToken); stored.Should().NotBeNull(); } @@ -102,10 +102,10 @@ public async Task Refresh_Should_Invalidate_Old_Code() var pkceService = scope.ServiceProvider.GetRequiredService(); var (artifact, _) = TestPkceFactory.Create(); - await store.StoreAsync(artifact.AuthorizationCode, artifact); + await store.StoreAsync(artifact.AuthorizationCode, artifact, cancellationToken: TestContext.Current.CancellationToken); var hub = TestHubFactory.Create(artifact); - var refreshed = await pkceService.RefreshAsync(hub); - var old = await store.GetAsync(artifact.AuthorizationCode); + var refreshed = await pkceService.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); + var old = await store.GetAsync(artifact.AuthorizationCode, cancellationToken: TestContext.Current.CancellationToken); old.Should().BeNull(); } @@ -119,10 +119,10 @@ public async Task Refresh_Should_Preserve_Context() var (artifact, _) = TestPkceFactory.Create(); var hub = TestHubFactory.Create(artifact); - var refreshed = await pkceService.RefreshAsync(hub); + var refreshed = await pkceService.RefreshAsync(hub, ct: TestContext.Current.CancellationToken); var store = scope.ServiceProvider.GetRequiredService(); - var newArtifact = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode)) as PkceAuthorizationArtifact; + var newArtifact = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode), cancellationToken: TestContext.Current.CancellationToken) as PkceAuthorizationArtifact; newArtifact!.Context.Device.Should().BeEquivalentTo(artifact.Context.Device); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs new file mode 100644 index 00000000..971422f2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using Microsoft.AspNetCore.Http; +using System.Net; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public class RateLimitingPartitionTests +{ + [Fact] + public void DuplicateServerRegistrationRetainsExistingGuard() + { + var services = new ServiceCollection(); + services.AddUltimateAuthServer(); + var error = Assert.Throws(() => services.AddUltimateAuthServer()); + Assert.Contains("policies already registered", error.Message); + } + + [Fact] + public async Task RejectionForwardsNativeRetryMetadata() + { + using var limiter = new System.Threading.RateLimiting.FixedWindowRateLimiter(new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); + using var acquired = limiter.AttemptAcquire(); + using var rejected = limiter.AttemptAcquire(); + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(); + context.Response.Body = new MemoryStream(); + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Login), new(), "login"); + await policy.OnRejected!(new Microsoft.AspNetCore.RateLimiting.OnRejectedContext + { HttpContext = context, Lease = rejected }, TestContext.Current.CancellationToken); + Assert.Equal(429, context.Response.StatusCode); + Assert.Equal("no-store", context.Response.Headers.CacheControl.ToString()); + Assert.True(int.Parse(context.Response.Headers.RetryAfter.ToString()) > 0); + } + + [Fact] + public void EveryEndpointHasTheReviewedClassification() + { + var all = Enum.GetValues().Select(UAuthEndpointClassification.Get).ToArray(); + Assert.Equal(81, all.Length); + Assert.Equal(20, all.Count(x => x.Group == UAuthRateLimitGroup.Sensitive)); + Assert.Equal(56, all.Count(x => x.Group == UAuthRateLimitGroup.Standard)); + Assert.Equal(5, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent)); + Assert.Equal(UAuthRateLimitGroup.Sensitive, UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous).Group); + Assert.Equal(UAuthRateLimitGroup.Standard, UAuthEndpointClassification.Get(UAuthEndpoint.VerifyUserIdentifierAdmin).Group); + Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.Logout).Group); + Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.AntiforgeryToken).Group); + } + + [Fact] + public void ValidatedActorsAreTenantIsolated_AndTargetsCannotChangeBudget() + { + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Refresh), new(), "refresh"); + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + var anonymous = policy.GetPartition(context).PartitionKey; + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-a", "actor"); + var actor = policy.GetPartition(context).PartitionKey; + Assert.NotEqual(anonymous, actor); + context.Request.RouteValues["userKey"] = "target-2"; + context.Request.Headers["X-UAuth-ClientProfile"] = "Maui"; + Assert.Equal(actor, policy.GetPartition(context).PartitionKey); + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-b", "actor"); + Assert.NotEqual(actor, policy.GetPartition(context).PartitionKey); + } + + [Fact] + public void LoginAlwaysUsesIp_AndForwardingHeadersAreNotTrusted() + { + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Login), new(), "login"); + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("::ffff:192.0.2.1"); + var key = policy.GetPartition(context).PartitionKey; + context.Request.Headers["X-Forwarded-For"] = "203.0.113.8"; + context.Request.Headers["CF-Connecting-IP"] = "203.0.113.9"; + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-a", "actor"); + Assert.Equal(key, policy.GetPartition(context).PartitionKey); + Assert.Equal("192.0.2.1", CodeBeam.UltimateAuth.Server.Infrastructure.ClientIpAddress.Resolve(context)); + } + + [Fact] + public void OptionsCloneDoesNotShareMutableRules() + { + var original = new UAuthRateLimitingOptions(); + Assert.Equal(30, original.Sensitive.PermitLimit); + Assert.Equal(120, original.Standard.PermitLimit); + Assert.Equal(600, original.Frequent.PermitLimit); + var reset = original.Resolve(UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous)); + Assert.Equal(5, reset.Rule.PermitLimit); + Assert.Equal(TimeSpan.FromMinutes(10), reset.Rule.Window); + original.Standard.PermitLimit = 240; + Assert.Equal(240, original.Resolve(UAuthEndpointClassification.Get(UAuthEndpoint.GetMyChains)).Rule.PermitLimit); + original.Endpoints[UAuthEndpoint.Login] = new() { PermitLimit = 8 }; + var clone = original.Clone(); + clone.Login.PermitLimit = 1; + clone.Endpoints[UAuthEndpoint.Login].PermitLimit = 2; + Assert.Equal(20, original.Login.PermitLimit); + Assert.Equal(8, original.Endpoints[UAuthEndpoint.Login].PermitLimit); + Assert.NotNull(UAuthRateLimitValidation.Validate(new() { Frequent = new() { PermitLimit = 0 } })); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index 76be15d8..19bf170d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -29,7 +29,7 @@ public async Task RefreshAsync_PureOpaque_WhenSessionIdMissing_ReturnsReauthWith var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureOpaque), - CreateRequest(sessionId: null)); + CreateRequest(sessionId: null), ct: TestContext.Current.CancellationToken); AssertReauth(result); validator.VerifyNoOtherCalls(); @@ -54,7 +54,7 @@ public async Task RefreshAsync_PureOpaque_WhenSessionInvalid_ReturnsReauthWithou var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureOpaque), - CreateRequest(SessionId)); + CreateRequest(SessionId), ct: TestContext.Current.CancellationToken); AssertReauth(result); touch.VerifyNoOtherCalls(); @@ -88,7 +88,7 @@ public async Task RefreshAsync_PureOpaque_WhenSessionRefreshSucceeds_ReturnsExpe var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureOpaque), - CreateRequest(SessionId)); + CreateRequest(SessionId), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(expectedOutcome); @@ -117,7 +117,7 @@ public async Task RefreshAsync_PureOpaque_WhenTouchFails_ReturnsReauth() var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureOpaque), - CreateRequest(SessionId)); + CreateRequest(SessionId), ct: TestContext.Current.CancellationToken); AssertReauth(result); rotation.VerifyNoOtherCalls(); @@ -130,7 +130,7 @@ public async Task RefreshAsync_PureJwt_WhenRefreshTokenMissing_ReturnsReauthWith var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureJwt), - CreateRequest(refreshToken: " ")); + CreateRequest(refreshToken: " "), ct: TestContext.Current.CancellationToken); AssertReauth(result); validator.VerifyNoOtherCalls(); @@ -157,7 +157,7 @@ public async Task RefreshAsync_PureJwt_WhenRotationSucceeds_ReturnsRotatedTokens var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureJwt), - CreateRequest(refreshToken: "refresh-token")); + CreateRequest(refreshToken: "refresh-token"), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); @@ -185,7 +185,7 @@ public async Task RefreshAsync_PureJwt_WhenRotationFails_ReturnsReauth() var result = await sut.RefreshAsync( CreateFlow(UAuthMode.PureJwt), - CreateRequest(refreshToken: "refresh-token")); + CreateRequest(refreshToken: "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); validator.VerifyNoOtherCalls(); @@ -204,7 +204,7 @@ public async Task RefreshAsync_Hybrid_WhenRequiredCredentialMissing_ReturnsReaut var result = await sut.RefreshAsync( CreateFlow(UAuthMode.Hybrid), - CreateRequest(sessionMarker is null ? null : SessionId, refreshToken)); + CreateRequest(sessionMarker is null ? null : SessionId, refreshToken), ct: TestContext.Current.CancellationToken); AssertReauth(result); validator.VerifyNoOtherCalls(); @@ -223,7 +223,7 @@ public async Task RefreshAsync_Hybrid_WhenSessionInvalid_ReturnsReauthWithoutRot var result = await sut.RefreshAsync( CreateFlow(UAuthMode.Hybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); rotation.VerifyNoOtherCalls(); @@ -266,7 +266,7 @@ public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthAfterSucces var result = await sut.RefreshAsync( CreateFlow(UAuthMode.Hybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); @@ -314,7 +314,7 @@ public async Task RefreshAsync_Hybrid_WhenAllStepsSucceed_ReturnsRotatedResultAn var result = await sut.RefreshAsync( CreateFlow(UAuthMode.Hybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); @@ -346,7 +346,7 @@ public async Task RefreshAsync_Hybrid_WhenTouchFails_DoesNotRotateRefreshToken() var result = await sut.RefreshAsync( CreateFlow(UAuthMode.Hybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); @@ -376,7 +376,7 @@ public async Task RefreshAsync_SemiHybrid_WhenSessionInvalid_ReturnsReauthWithou var result = await sut.RefreshAsync( CreateFlow(UAuthMode.SemiHybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); rotation.VerifyNoOtherCalls(); @@ -405,7 +405,7 @@ public async Task RefreshAsync_SemiHybrid_WhenRotationSucceeds_ReturnsRotatedWit var result = await sut.RefreshAsync( CreateFlow(UAuthMode.SemiHybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); @@ -437,7 +437,7 @@ public async Task RefreshAsync_SemiHybrid_WhenRotationFails_ReturnsReauthWithout var result = await sut.RefreshAsync( CreateFlow(UAuthMode.SemiHybrid), - CreateRequest(SessionId, "refresh-token")); + CreateRequest(SessionId, "refresh-token"), ct: TestContext.Current.CancellationToken); AssertReauth(result); touch.VerifyNoOtherCalls(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 8d26537d..ffd2d6c3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -35,7 +35,7 @@ public async Task RotateAsync_WhenValidationIsInvalid_ReturnsFailedWithoutCreati var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object, claimsProvider.Object); - var result = await sut.RotateAsync(CreateFlow(), CreateContext()); + var result = await sut.RotateAsync(CreateFlow(), CreateContext(), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); @@ -73,7 +73,7 @@ public async Task RotateAsync_WhenTokenIsNotFound_ReturnsFailedWithoutCreatingSt var result = await sut.RotateAsync( CreateFlow(), - CreateContext()); + CreateContext(), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); @@ -131,7 +131,7 @@ public async Task RotateAsync_WhenTokenIsExpired_ReturnsFailedWithoutMutatingSto var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), - CreateContext(token.SessionId)); + CreateContext(token.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); @@ -225,7 +225,7 @@ await sut.RotateAsync( CreateFlow( tenant, multiTenant: true), - CreateContext(sessionId)); + CreateContext(sessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should() .BeFalse(); @@ -288,7 +288,7 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), - CreateContext(validation.SessionId)); + CreateContext(validation.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); @@ -345,7 +345,7 @@ public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object, claimsProvider.Object); - var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeTrue(); result.Result.AccessToken.Should().BeSameAs(accessToken); @@ -420,7 +420,7 @@ public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForToken CreateFlow( tenant, multiTenant: true), - CreateContext(validation.SessionId)); + CreateContext(validation.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeTrue(); @@ -462,7 +462,7 @@ public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIs CreateFlow( TenantKey.Single, multiTenant: false), - CreateContext(validation.SessionId)); + CreateContext(validation.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeTrue(); @@ -519,7 +519,7 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), - CreateContext(validation.SessionId)); + CreateContext(validation.SessionId), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); @@ -639,7 +639,7 @@ public async Task RotateAsync_WhenIssueRefreshDisabled_ShouldFailWithoutValidati var result = await sut.RotateAsync( flow, - CreateContext()); + CreateContext(), ct: TestContext.Current.CancellationToken); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs index 3938678b..5ec03c83 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs @@ -27,7 +27,7 @@ public async Task Create_WithAuthenticatedUser_ShouldSetActor() var http = CreateHttpContext(authenticated: true, userKey); - var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read"); + var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read", ct: TestContext.Current.CancellationToken); result.IsAuthenticated.Should().BeTrue(); result.ActorUserKey.Should().Be(userKey); @@ -40,7 +40,7 @@ public async Task Create_WithAnonymousUser_ShouldNotSetActorUserKey() { var http = CreateHttpContext(authenticated: false); - var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read"); + var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read", ct: TestContext.Current.CancellationToken); result.IsAuthenticated.Should().BeFalse(); result.ActorUserKey.Should().BeNull(); @@ -56,7 +56,7 @@ public async Task Create_ShouldDeriveResourceFromAction(string action, string ex { var http = CreateHttpContext(authenticated: false); - var result = await ResourceAccessContextBuilder.CreateAsync(http, action); + var result = await ResourceAccessContextBuilder.CreateAsync(http, action, ct: TestContext.Current.CancellationToken); result.Resource.Should().Be(expectedResource); result.Action.Should().Be(action); @@ -67,7 +67,7 @@ public async Task Create_ShouldNotTreatCurrentUserAsSystemActor() { var http = CreateHttpContext(authenticated: true, CreateUserKey()); - var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read"); + var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read", ct: TestContext.Current.CancellationToken); result.IsSystemActor.Should().BeFalse(); } @@ -77,7 +77,7 @@ public async Task Create_ShouldNotSetTargetUser() { var http = CreateHttpContext(authenticated: true, CreateUserKey()); - var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read"); + var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read", ct: TestContext.Current.CancellationToken); result.TargetUserKey.Should().BeNull(); } @@ -89,7 +89,7 @@ public async Task Create_ShouldPropagateTenantToActorAndResource() var http = CreateHttpContext(authenticated: true, userKey: CreateUserKey(), tenant: tenant); - var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read"); + var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read", ct: TestContext.Current.CancellationToken); result.ActorTenant.Should().Be(tenant); result.ResourceTenant.Should().Be(tenant); @@ -179,7 +179,7 @@ public async Task Create_WithResourceRuntime_ShouldResolveTrustedActorRolesFromC // Act var context = await ResourceAccessContextBuilder.CreateAsync( http, - "users.read"); + "users.read", ct: TestContext.Current.CancellationToken); // Assert context.IsAuthenticated.Should().BeTrue(); @@ -255,7 +255,7 @@ public async Task Create_WithResourceRuntime_ShouldIgnoreRolesFromOtherIdentitie // Act var context = await ResourceAccessContextBuilder.CreateAsync( http, - "users.read"); + "users.read", ct: TestContext.Current.CancellationToken); // Assert context.ActorRoles.Should().ContainSingle(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs index f9ed17f2..80fed14b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -31,13 +31,13 @@ public async Task GetUserChainsAsync_ReturnsRequestedPage() var root = await CreateRootAsync(store, user); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-30))); + CreateChain(root, Now.AddMinutes(-30)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-20))); + CreateChain(root, Now.AddMinutes(-20)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-10))); + CreateChain(root, Now.AddMinutes(-10)), ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user), @@ -46,7 +46,7 @@ await store.CreateChainAsync( { PageNumber = 2, PageSize = 2 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(3); result.PageNumber.Should().Be(2); @@ -70,8 +70,8 @@ public async Task GetUserChainsAsync_MarksActorChainAsCurrentDevice() root, Now.AddMinutes(-5)); - await store.CreateChainAsync(actorChain); - await store.CreateChainAsync(otherChain); + await store.CreateChainAsync(actorChain, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(otherChain, ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user, actorChain.ChainId), @@ -80,7 +80,7 @@ public async Task GetUserChainsAsync_MarksActorChainAsCurrentDevice() { PageNumber = 1, PageSize = 10 - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); @@ -111,9 +111,9 @@ public async Task GetUserChainsAsync_SortByChainIdAscending_SortsByChainId() var second = CreateChain(root, Now.AddMinutes(-20)); var third = CreateChain(root, Now.AddMinutes(-10)); - await store.CreateChainAsync(first); - await store.CreateChainAsync(second); - await store.CreateChainAsync(third); + await store.CreateChainAsync(first, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(second, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(third, ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user), @@ -124,7 +124,7 @@ public async Task GetUserChainsAsync_SortByChainIdAscending_SortsByChainId() PageSize = 10, SortBy = nameof(SessionChainSummary.ChainId), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.ChainId.Value) @@ -141,13 +141,13 @@ public async Task GetUserChainsAsync_SortByChainIdDescending_SortsByChainId() var root = await CreateRootAsync(store, user); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-30))); + CreateChain(root, Now.AddMinutes(-30)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-20))); + CreateChain(root, Now.AddMinutes(-20)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-10))); + CreateChain(root, Now.AddMinutes(-10)), ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user), @@ -158,7 +158,7 @@ await store.CreateChainAsync( PageSize = 10, SortBy = nameof(SessionChainSummary.ChainId), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.ChainId.Value) @@ -176,13 +176,13 @@ public async Task GetUserChainsAsync_SortByCreatedAtAscending_SortsByCreatedAt() // Deliberately insert in non-chronological order. await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-5))); + CreateChain(root, Now.AddMinutes(-5)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-30))); + CreateChain(root, Now.AddMinutes(-30)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-15))); + CreateChain(root, Now.AddMinutes(-15)), ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user), @@ -193,7 +193,7 @@ await store.CreateChainAsync( PageSize = 10, SortBy = nameof(SessionChainSummary.CreatedAt), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.CreatedAt) @@ -210,13 +210,13 @@ public async Task GetUserChainsAsync_SortByCreatedAtDescending_SortsByCreatedAt( var root = await CreateRootAsync(store, user); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-5))); + CreateChain(root, Now.AddMinutes(-5)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-30))); + CreateChain(root, Now.AddMinutes(-30)), ct: TestContext.Current.CancellationToken); await store.CreateChainAsync( - CreateChain(root, Now.AddMinutes(-15))); + CreateChain(root, Now.AddMinutes(-15)), ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainsAsync( Context(user), @@ -227,7 +227,7 @@ await store.CreateChainAsync( PageSize = 10, SortBy = nameof(SessionChainSummary.CreatedAt), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items .Select(x => x.CreatedAt) @@ -268,7 +268,7 @@ public async Task GetUserChainDetailAsync_WhenChainBelongsToDifferentUser_Throws ownerRoot, Now.AddMinutes(-10)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var act = () => sut.GetUserChainDetailAsync( Context(requestedUser), @@ -291,7 +291,7 @@ public async Task GetUserChainDetailAsync_ReturnsSessionsNewestFirst() root, Now.AddHours(-1)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var older = CreateSession( user, @@ -305,13 +305,13 @@ public async Task GetUserChainDetailAsync_ReturnsSessionsNewestFirst() Now.AddMinutes(-10), "newer-session"); - await store.CreateSessionAsync(older); - await store.CreateSessionAsync(newer); + await store.CreateSessionAsync(older, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(newer, ct: TestContext.Current.CancellationToken); var result = await sut.GetUserChainDetailAsync( Context(user), user, - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.ChainId.Should().Be(chain.ChainId); @@ -345,16 +345,16 @@ public async Task RevokeUserSessionAsync_WhenSessionBelongsToUser_RevokesSession Now.AddMinutes(-30), "session-to-revoke"); - await store.CreateChainAsync(chain); - await store.CreateSessionAsync(session); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(session, ct: TestContext.Current.CancellationToken); await sut.RevokeUserSessionAsync( Context(user), user, - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); var persisted = - await store.GetSessionAsync(session.SessionId); + await store.GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -381,8 +381,8 @@ public async Task RevokeUserSessionAsync_WhenSessionBelongsToDifferentUser_DoesN Now.AddMinutes(-30), "foreign-session"); - await store.CreateChainAsync(chain); - await store.CreateSessionAsync(session); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(session, ct: TestContext.Current.CancellationToken); var act = () => sut.RevokeUserSessionAsync( Context(caller), @@ -391,7 +391,7 @@ public async Task RevokeUserSessionAsync_WhenSessionBelongsToDifferentUser_DoesN await act.Should().ThrowAsync(); - var persisted = await store.GetSessionAsync(session.SessionId); + var persisted = await store.GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeFalse(); } @@ -412,18 +412,18 @@ public async Task RevokeUserChainAsync_WhenChainBelongsToUser_RevokesChain() root, Now.AddHours(-1)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var result = await sut.RevokeUserChainAsync( Context(user), user, - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.CurrentChain.Should().BeFalse(); result.RootRevoked.Should().BeFalse(); var persisted = - await store.GetChainAsync(chain.ChainId); + await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -442,18 +442,18 @@ public async Task RevokeUserChainAsync_WhenRevokingActorChain_ReportsCurrentChai root, Now.AddHours(-1)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var result = await sut.RevokeUserChainAsync( Context(user, chain.ChainId), user, - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.CurrentChain.Should().BeTrue(); result.RootRevoked.Should().BeFalse(); var persisted = - await store.GetChainAsync(chain.ChainId); + await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -487,7 +487,7 @@ public async Task RevokeUserChainAsync_WhenChainBelongsToDifferentUser_MustNotRe ownerRoot, Now.AddHours(-1)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var act = () => sut.RevokeUserChainAsync( Context(caller), @@ -498,7 +498,7 @@ await act.Should() .ThrowAsync(); var persisted = - await store.GetChainAsync(chain.ChainId); + await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeFalse(); @@ -528,23 +528,23 @@ public async Task RevokeAllChainsAsync_WithExceptChain_LeavesExceptedChainActive root, Now.AddMinutes(-10)); - await store.CreateChainAsync(keep); - await store.CreateChainAsync(revoke1); - await store.CreateChainAsync(revoke2); + await store.CreateChainAsync(keep, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(revoke1, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(revoke2, ct: TestContext.Current.CancellationToken); await sut.RevokeAllChainsAsync( Context(user), user, - keep.ChainId); + keep.ChainId, ct: TestContext.Current.CancellationToken); var persistedKeep = - await store.GetChainAsync(keep.ChainId); + await store.GetChainAsync(keep.ChainId, ct: TestContext.Current.CancellationToken); var persistedRevoke1 = - await store.GetChainAsync(revoke1.ChainId); + await store.GetChainAsync(revoke1.ChainId, ct: TestContext.Current.CancellationToken); var persistedRevoke2 = - await store.GetChainAsync(revoke2.ChainId); + await store.GetChainAsync(revoke2.ChainId, ct: TestContext.Current.CancellationToken); persistedKeep!.IsRevoked.Should().BeFalse(); persistedRevoke1!.IsRevoked.Should().BeTrue(); @@ -567,18 +567,18 @@ public async Task RevokeAllChainsAsync_WithoutExceptChain_RevokesAllChains() root, Now.AddMinutes(-10)); - await store.CreateChainAsync(first); - await store.CreateChainAsync(second); + await store.CreateChainAsync(first, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(second, ct: TestContext.Current.CancellationToken); await sut.RevokeAllChainsAsync( Context(user), user, - exceptChainId: null); + exceptChainId: null, ct: TestContext.Current.CancellationToken); - (await store.GetChainAsync(first.ChainId))! + (await store.GetChainAsync(first.ChainId, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.GetChainAsync(second.ChainId))! + (await store.GetChainAsync(second.ChainId, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); } @@ -598,11 +598,11 @@ public async Task LogoutDeviceAsync_WhenActorChainMatches_ReportsCurrentChain() root, Now.AddHours(-1)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var result = await sut.LogoutDeviceAsync( Context(user, chain.ChainId), - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.CurrentChain.Should().BeTrue(); result.RootRevoked.Should().BeFalse(); @@ -624,8 +624,8 @@ public async Task LogoutOtherDevicesAsync_RevokesSessionsOnOtherChains() root, Now.AddHours(-1)); - await store.CreateChainAsync(currentChain); - await store.CreateChainAsync(otherChain); + await store.CreateChainAsync(currentChain, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(otherChain, ct: TestContext.Current.CancellationToken); var currentSession = CreateSession( user, @@ -639,19 +639,19 @@ public async Task LogoutOtherDevicesAsync_RevokesSessionsOnOtherChains() Now.AddMinutes(-20), "other-session"); - await store.CreateSessionAsync(currentSession); - await store.CreateSessionAsync(otherSession); + await store.CreateSessionAsync(currentSession, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(otherSession, ct: TestContext.Current.CancellationToken); await sut.LogoutOtherDevicesAsync( Context(user, currentChain.ChainId), user, - currentChain.ChainId); + currentChain.ChainId, ct: TestContext.Current.CancellationToken); var persistedCurrent = - await store.GetSessionAsync(currentSession.SessionId); + await store.GetSessionAsync(currentSession.SessionId, ct: TestContext.Current.CancellationToken); var persistedOther = - await store.GetSessionAsync(otherSession.SessionId); + await store.GetSessionAsync(otherSession.SessionId, ct: TestContext.Current.CancellationToken); persistedCurrent!.IsRevoked.Should().BeFalse(); persistedOther!.IsRevoked.Should().BeTrue(); @@ -673,8 +673,8 @@ public async Task LogoutAllDevicesAsync_RevokesAllUserSessions() root, Now.AddHours(-1)); - await store.CreateChainAsync(firstChain); - await store.CreateChainAsync(secondChain); + await store.CreateChainAsync(firstChain, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(secondChain, ct: TestContext.Current.CancellationToken); var firstSession = CreateSession( user, @@ -688,17 +688,17 @@ public async Task LogoutAllDevicesAsync_RevokesAllUserSessions() Now.AddMinutes(-20), "second-session"); - await store.CreateSessionAsync(firstSession); - await store.CreateSessionAsync(secondSession); + await store.CreateSessionAsync(firstSession, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(secondSession, ct: TestContext.Current.CancellationToken); await sut.LogoutAllDevicesAsync( Context(user), - user); + user, ct: TestContext.Current.CancellationToken); - (await store.GetSessionAsync(firstSession.SessionId))! + (await store.GetSessionAsync(firstSession.SessionId, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.GetSessionAsync(secondSession.SessionId))! + (await store.GetSessionAsync(secondSession.SessionId, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); } @@ -718,23 +718,85 @@ public async Task RevokeRootAsync_RevokesRootAndItsChains() var secondChain = CreateChain(root, Now.AddMinutes(-10)); - await store.CreateChainAsync(firstChain); - await store.CreateChainAsync(secondChain); + await store.CreateChainAsync(firstChain, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(secondChain, ct: TestContext.Current.CancellationToken); - await sut.RevokeRootAsync(Context(user), user); + await sut.RevokeRootAsync(Context(user), user, ct: TestContext.Current.CancellationToken); - var activeRoot = await store.GetActiveRootByUserAsync(user); + var activeRoot = await store.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); activeRoot.Should().BeNull(); - var persistedRoot = await store.GetRootByIdAsync(root.RootId); + var persistedRoot = await store.GetRootByIdAsync(root.RootId, ct: TestContext.Current.CancellationToken); persistedRoot.Should().NotBeNull(); persistedRoot!.IsRevoked.Should().BeTrue(); persistedRoot.RevokedAt.Should().Be(Now); - (await store.GetChainAsync(firstChain.ChainId))!.IsRevoked.Should().BeTrue(); - (await store.GetChainAsync(secondChain.ChainId))!.IsRevoked.Should().BeTrue(); + (await store.GetChainAsync(firstChain.ChainId, ct: TestContext.Current.CancellationToken))!.IsRevoked.Should().BeTrue(); + (await store.GetChainAsync(secondChain.ChainId, ct: TestContext.Current.CancellationToken))!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task GetUserChainsAsync_ReturnsTotalSessionCountPerChain() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + var root = await CreateRootAsync(store, user); + + var firstChain = CreateChain(root, Now.AddMinutes(-30)); + var secondChain = CreateChain(root, Now.AddMinutes(-20)); + + await store.CreateChainAsync(firstChain, ct: TestContext.Current.CancellationToken); + await store.CreateChainAsync(secondChain, ct: TestContext.Current.CancellationToken); + + var firstSession = CreateSession( + user, firstChain.ChainId, Now.AddMinutes(-25), "first-1"); + + var rotatedSession = CreateSession( + user, firstChain.ChainId, Now.AddMinutes(-15), "first-2"); + + var secondSession = CreateSession( + user, secondChain.ChainId, Now.AddMinutes(-10), "second-1"); + + await store.CreateSessionAsync(firstSession, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(rotatedSession, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(secondSession, ct: TestContext.Current.CancellationToken); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest { PageNumber = 1, PageSize = 10 }, ct: TestContext.Current.CancellationToken); + + result.Items.Single(x => x.ChainId == firstChain.ChainId) + .SessionCount.Should().Be(2); + + result.Items.Single(x => x.ChainId == secondChain.ChainId) + .SessionCount.Should().Be(1); + } + + [Fact] + public async Task GetUserChainsAsync_RevokedSessionsRemainCounted() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + var root = await CreateRootAsync(store, user); + + var chain = CreateChain(root, Now.AddMinutes(-30)); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); + + var first = CreateSession(user, chain.ChainId, Now.AddMinutes(-20), "revoked-1"); + + var second = CreateSession(user, chain.ChainId, Now.AddMinutes(-10), "revoked-2"); + + await store.CreateSessionAsync(first, ct: TestContext.Current.CancellationToken); + await store.CreateSessionAsync(second, ct: TestContext.Current.CancellationToken); + + await store.RevokeChainCascadeAsync(chain.ChainId, Now, ct: TestContext.Current.CancellationToken); + + var result = await sut.GetUserChainsAsync(Context(user), user, new PageRequest { PageNumber = 1, PageSize = 10 }, ct: TestContext.Current.CancellationToken); + + result.Items.Single(x => x.ChainId == chain.ChainId).SessionCount.Should().Be(2); } // --------------------------------------------------------------------- @@ -820,6 +882,7 @@ private static AccessContext Context( targetUserKey: user, resourceTenant: TenantKey.Single, action: "session.manage", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs index 913aefca..b73999d4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Moq; using System.Text; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -659,8 +660,8 @@ private static AccessContext CreateAccess( targetUserKey: targetUser, resourceTenant: flow.Tenant, action: action, - attributes: - new Dictionary()); + clientProfile: UAuthClientProfile.NotSpecified, + attributes: new Dictionary()); } private static AuthFlowContext CreateUnauthenticatedFlow() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs index cae98eea..abf83db7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs @@ -25,7 +25,7 @@ public async Task RefreshAsync_WhenValidationIsInvalid_ReturnsReauthRequired() SessionValidationResult.Invalid(SessionState.Revoked), Policy(), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -50,7 +50,7 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() validation, Policy(), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -71,7 +71,7 @@ public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsReauthRequired() validation, Policy(), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -86,7 +86,7 @@ public async Task RefreshAsync_WhenSessionDoesNotExist_ReturnsReauthRequired() var store = factory.Create(validation.Tenant); var chain = CreateChain(validation, Now.AddMinutes(-2)); - await store.CreateChainAsync(chain); + await store.CreateChainAsync(chain, ct: TestContext.Current.CancellationToken); var sut = new SessionTouchService(factory); @@ -94,7 +94,7 @@ public async Task RefreshAsync_WhenSessionDoesNotExist_ReturnsReauthRequired() validation, Policy(), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -109,7 +109,7 @@ public async Task RefreshAsync_WhenChainIsRevoked_ReturnsReauthRequired() var store = factory.Create(validation.Tenant); var revoked = chain.Revoke(Now.AddMinutes(-1)); - await store.SaveChainAsync(revoked, chain.Version); + await store.SaveChainAsync(revoked, chain.Version, ct: TestContext.Current.CancellationToken); var sut = new SessionTouchService(factory); @@ -117,13 +117,13 @@ public async Task RefreshAsync_WhenChainIsRevoked_ReturnsReauthRequired() validation, Policy(), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - var persisted = await store.GetChainAsync(chain.ChainId); + var persisted = await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted!.IsRevoked.Should().BeTrue(); persisted.TouchCount.Should().Be(revoked.TouchCount); @@ -139,10 +139,10 @@ public async Task RefreshAsync_WhenSessionIsRevoked_ReturnsReauthRequired() var store = factory.Create(validation.Tenant); var revoked = session.Revoke(Now.AddMinutes(-1)); - await store.SaveSessionAsync(revoked, session.Version); + await store.SaveSessionAsync(revoked, session.Version, ct: TestContext.Current.CancellationToken); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.IfNeeded, Now); + validation, Policy(), SessionTouchMode.IfNeeded, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -158,7 +158,7 @@ await CreateValidSessionAsync( sessionExpiresAt: Now); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.Force, Now); + validation, Policy(), SessionTouchMode.Force, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -175,7 +175,7 @@ public async Task RefreshAsync_WhenChainIdleTimeoutElapsed_ReturnsReauthRequired validation, Policy(idleTimeout: TimeSpan.FromMinutes(10)), SessionTouchMode.Force, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); result.RequiresReauth.Should().BeTrue(); @@ -189,15 +189,15 @@ public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWith await CreateValidSessionAsync(Now.AddMinutes(-4)); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.IfNeeded, Now); + validation, Policy(), SessionTouchMode.IfNeeded, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.RequiresReauth.Should().BeFalse(); result.DidTouch.Should().BeFalse(); var store = factory.Create(validation.Tenant); - var persistedChain = await store.GetChainAsync(chain.ChainId); - var persistedSession = await store.GetSessionAsync(session.SessionId); + var persistedChain = await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); + var persistedSession = await store.GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); persistedChain!.LastSeenAt.Should().Be(chain.LastSeenAt); persistedChain.TouchCount.Should().Be(0); @@ -214,14 +214,14 @@ public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain( await CreateValidSessionAsync(Now.AddMinutes(-6)); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.IfNeeded, Now); + validation, Policy(), SessionTouchMode.IfNeeded, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); result.SessionId.Should().Be(validation.SessionId); var persisted = await factory.Create(validation.Tenant) - .GetChainAsync(chain.ChainId); + .GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted!.LastSeenAt.Should().Be(Now); persisted.TouchCount.Should().Be(1); @@ -235,13 +235,13 @@ public async Task RefreshAsync_WhenTouchIntervalExactlyElapsed_TouchesChain() await CreateValidSessionAsync(Now.AddMinutes(-5)); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.IfNeeded, Now); + validation, Policy(), SessionTouchMode.IfNeeded, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); var persisted = await factory.Create(validation.Tenant) - .GetChainAsync(chain.ChainId); + .GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted!.LastSeenAt.Should().Be(Now); persisted.TouchCount.Should().Be(1); @@ -256,13 +256,13 @@ public async Task RefreshAsync_WhenTouchIntervalIsNull_TouchesEveryRefresh() var policy = PolicyWithoutTouchInterval(); var result = await new SessionTouchService(factory).RefreshAsync( - validation, policy, SessionTouchMode.IfNeeded, Now); + validation, policy, SessionTouchMode.IfNeeded, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); var persisted = await factory.Create(validation.Tenant) - .GetChainAsync(chain.ChainId); + .GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted!.LastSeenAt.Should().Be(Now); persisted.TouchCount.Should().Be(1); @@ -275,13 +275,13 @@ public async Task RefreshAsync_WhenForceIsUsed_TouchesBeforeInterval() await CreateValidSessionAsync(Now.AddMinutes(-1)); var result = await new SessionTouchService(factory).RefreshAsync( - validation, Policy(), SessionTouchMode.Force, Now); + validation, Policy(), SessionTouchMode.Force, Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); var persisted = await factory.Create(validation.Tenant) - .GetChainAsync(chain.ChainId); + .GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persisted!.LastSeenAt.Should().Be(Now); } @@ -302,14 +302,14 @@ await CreateValidSessionAsync( slidingExpiration: true, lifetime: TimeSpan.FromHours(1)), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); var store = factory.Create(validation.Tenant); - var persistedSession = await store.GetSessionAsync(session.SessionId); - var persistedChain = await store.GetChainAsync(chain.ChainId); + var persistedSession = await store.GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); + var persistedChain = await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); persistedSession!.ExpiresAt.Should().Be(Now.AddHours(1)); persistedSession.Version.Should().Be(session.Version + 1); @@ -330,13 +330,13 @@ await CreateValidSessionAsync( validation, Policy(slidingExpiration: false), SessionTouchMode.IfNeeded, - Now); + Now, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); var persisted = await factory.Create(validation.Tenant) - .GetSessionAsync(session.SessionId); + .GetSessionAsync(session.SessionId, ct: TestContext.Current.CancellationToken); persisted!.ExpiresAt.Should().Be(originalExpiry); persisted.Version.Should().Be(session.Version); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs index c8bcf379..56b021fe 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs @@ -25,7 +25,7 @@ public async Task TryLogin_Should_Return_Preview_On_Failure() { Identifier = "user", Secret = "wrong" - }); + }, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.IsSuccess.Should().BeFalse(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs index 2a49a93f..f0e8f9f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs @@ -51,7 +51,7 @@ public async Task LoginAsync_DelegatesToLoginOrchestrator() var result = await sut.LoginAsync( flow, - request); + request, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); @@ -107,7 +107,7 @@ public async Task LoginAsync_WithClientProfileOverride_RecreatesFlowBeforeLogin( var result = await sut.LoginAsync( originalFlow, execution, - request); + request, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); @@ -171,7 +171,7 @@ public async Task LoginAsync_WithDeviceOverride_RecreatesFlowBeforeLogin() var result = await sut.LoginAsync( originalFlow, execution, - request); + request, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); @@ -237,7 +237,7 @@ public async Task LoginAsync_WithClientProfileAndDeviceOverride_AppliesOverrides var result = await sut.LoginAsync( originalFlow, execution, - request); + request, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); @@ -292,7 +292,7 @@ public async Task InternalLoginAsync_DelegatesToInternalLoginOrchestrator() var result = await sut.LoginAsync( flow, request, - options); + options, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); @@ -364,7 +364,7 @@ public async Task InternalLoginAsync_WithExecutionOverrides_UsesRecreatedFlow() originalFlow, execution, request, - options); + options, ct: TestContext.Current.CancellationToken); result.Should().BeSameAs(expected); } @@ -411,7 +411,7 @@ await sut.LogoutAsync( new LogoutRequest { SessionId = sessionId - }); + }, ct: TestContext.Current.CancellationToken); eventRaised.Should().BeFalse(); } @@ -454,7 +454,7 @@ await sut.LogoutAsync( new LogoutRequest { SessionId = sessionId - }); + }, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -506,7 +506,7 @@ await sut.LogoutAsync( new LogoutRequest { SessionId = sessionId - }); + }, ct: TestContext.Current.CancellationToken); eventRaised.Should().BeFalse(); } @@ -583,7 +583,7 @@ await sut.LogoutAllAsync( new LogoutAllRequest { ExceptCurrent = false - }); + }, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured!.UserKey.Should().Be(user); @@ -637,7 +637,7 @@ await sut.LogoutAllAsync( new LogoutAllRequest { ExceptCurrent = true - }); + }, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured!.UserKey.Should().Be(user); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index b3b93559..6384dbca 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -1,15 +1,16 @@ -ο»Ώusing System.Security.Claims; +ο»Ώusing AwesomeAssertions; 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.Core.Options; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Server.Authorization; using CodeBeam.UltimateAuth.Server.Infrastructure; -using AwesomeAssertions; using Microsoft.AspNetCore.Http; using Moq; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -56,7 +57,7 @@ public async Task ExecuteAsync_WhenAllowed_ShouldExecuteCommandExactlyOnce() return Task.CompletedTask; }); - await _sut.ExecuteAsync(context, command); + await _sut.ExecuteAsync(context, command, ct: TestContext.Current.CancellationToken); executions.Should().Be(1); } @@ -178,7 +179,7 @@ public async Task ExecuteAsync_ShouldPassEnrichedContextToPolicyProvider() await _sut.ExecuteAsync( original, - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -206,7 +207,7 @@ public async Task ExecuteAsync_ShouldCompileWildcardPermissionClaims() await _sut.ExecuteAsync( CreateContext(), - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); var compiled = GetCompiledPermissions(captured!); @@ -232,7 +233,7 @@ public async Task ExecuteAsync_WithNoPermissionClaims_ShouldProvideEmptyPermissi await _sut.ExecuteAsync( CreateContext(), - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); var compiled = GetCompiledPermissions(captured!); @@ -266,7 +267,7 @@ public async Task ExecuteAsync_ShouldPassEnrichedContextToAuthority() await _sut.ExecuteAsync( CreateContext(), - new AccessCommand(_ => Task.CompletedTask)); + new AccessCommand(_ => Task.CompletedTask), ct: TestContext.Current.CancellationToken); authorityContext.Should().NotBeNull(); @@ -290,7 +291,7 @@ public async Task ExecuteAsyncOfT_WhenAllowed_ShouldReturnCommandResult() var command = new AccessCommand( _ => Task.FromResult("result")); - var result = await _sut.ExecuteAsync(context, command); + var result = await _sut.ExecuteAsync(context, command, ct: TestContext.Current.CancellationToken); result.Should().Be("result"); } @@ -440,7 +441,7 @@ public async Task ExecuteAsync_ShouldAwaitAuthorityBeforeExecutingCommand() var executionTask = _sut.ExecuteAsync( CreateContext(), - command); + command, ct: TestContext.Current.CancellationToken); executed.Should().BeFalse(); @@ -474,6 +475,7 @@ private static AccessContext CreateContext() targetUserKey: null, resourceTenant: default, action: "users.read", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs index e781ebcf..e7a39459 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs @@ -32,7 +32,7 @@ public async Task ValidateSessionAsync_WhenSessionDoesNotExist_ReturnsNotFound() It.IsAny())) .ReturnsAsync((UAuthSession?)null); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.NotFound); @@ -60,7 +60,7 @@ public async Task ValidateSessionAsync_WhenSessionIsExpired_ReturnsExpired() SetupSession(fixture); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Expired); @@ -88,7 +88,7 @@ public async Task ValidateSessionAsync_WhenSessionIsRevoked_ReturnsRevoked() It.IsAny())) .ReturnsAsync(revoked); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -120,7 +120,7 @@ public async Task ValidateSessionAsync_WhenChainDoesNotExist_ReturnsRevoked() It.IsAny())) .ReturnsAsync((UAuthSessionChain?)null); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -150,7 +150,7 @@ public async Task ValidateSessionAsync_WhenChainIsRevoked_ReturnsRevoked() It.IsAny())) .ReturnsAsync(revokedChain); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -170,7 +170,7 @@ public async Task ValidateSessionAsync_WhenChainIsAbsolutelyExpired_ReturnsExpir SetupSessionAndChain(fixture); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Expired); @@ -191,7 +191,7 @@ public async Task ValidateSessionAsync_WhenChainIdleTimeoutIsExceeded_ReturnsExp SetupSessionAndChain(fixture); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Expired); @@ -220,7 +220,7 @@ public async Task ValidateSessionAsync_WhenRootDoesNotExist_ReturnsRevoked() It.IsAny())) .ReturnsAsync((UAuthSessionRoot?)null); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -249,7 +249,7 @@ public async Task ValidateSessionAsync_WhenRootIsRevoked_ReturnsRevoked() It.IsAny())) .ReturnsAsync(revokedRoot); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.Revoked); @@ -281,7 +281,7 @@ public async Task ValidateSessionAsync_WhenChainBelongsToDifferentRoot_ReturnsSe It.IsAny())) .ReturnsAsync(differentRoot); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.SecurityMismatch); @@ -311,7 +311,7 @@ public async Task ValidateSessionAsync_WhenSecurityVersionDoesNotMatch_ReturnsSe It.IsAny())) .ReturnsAsync(updatedRoot); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.SecurityMismatch); @@ -338,7 +338,7 @@ public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorIsReject SetupValidAggregate(fixture); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.State.Should().Be(SessionState.DeviceMismatch); @@ -356,13 +356,66 @@ public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorIsReject } [Fact] - public async Task ValidateSessionAsync_WhenRequestHasNoDeviceId_AllowsValidation() + public async Task ValidateSessionAsync_WhenRequestHasNoDeviceIdAndBehaviorIsReject_ReturnsDeviceMismatch() + { + var fixture = CreateFixture(requestDevice: DeviceContext.Anonymous(), deviceMismatchBehavior: DeviceMismatchBehavior.Reject); + + SetupValidAggregate(fixture); + + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.DeviceMismatch); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + result.RootId.Should().Be(fixture.Root.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + + [Theory] + [InlineData(DeviceMismatchBehavior.Allow)] + [InlineData(DeviceMismatchBehavior.AllowAndRebind)] + public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorAllows_ReturnsActive( + DeviceMismatchBehavior behavior) { var fixture = CreateFixture( - requestDevice: DeviceContext.Anonymous()); + requestDevice: TestDevice.Alternative(), + deviceMismatchBehavior: behavior); - var claims = ClaimsSnapshot.From( - ("uauth:permission", "orders.read")); + SetupValidAggregate(fixture); + + fixture.ClaimsProvider + .Setup(x => x.GetClaimsAsync( + fixture.Tenant, + fixture.User, + It.IsAny())) + .ReturnsAsync(ClaimsSnapshot.Empty); + + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeTrue(); + result.State.Should().Be(SessionState.Active); + result.BoundDeviceId.Should().Be(fixture.Chain.Device.DeviceId); + } + + [Theory] + [InlineData(DeviceMismatchBehavior.Allow)] + [InlineData(DeviceMismatchBehavior.AllowAndRebind)] + public async Task ValidateSessionAsync_WhenRequestHasNoDeviceIdAndBehaviorAllows_ReturnsActive(DeviceMismatchBehavior behavior) + { + var fixture = CreateFixture(requestDevice: DeviceContext.Anonymous(), deviceMismatchBehavior: behavior); SetupValidAggregate(fixture); @@ -371,9 +424,11 @@ public async Task ValidateSessionAsync_WhenRequestHasNoDeviceId_AllowsValidation fixture.Tenant, fixture.User, It.IsAny())) - .ReturnsAsync(claims); + .ReturnsAsync(ClaimsSnapshot.Empty); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.State.Should().Be(SessionState.Active); @@ -401,7 +456,7 @@ public async Task ValidateSessionAsync_WhenAggregateIsValid_ReturnsActiveResult( It.IsAny())) .ReturnsAsync(claims); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.State.Should().Be(SessionState.Active); @@ -417,6 +472,33 @@ public async Task ValidateSessionAsync_WhenAggregateIsValid_ReturnsActiveResult( result.BoundDeviceId.Should().Be(fixture.Chain.Device.DeviceId); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidateSessionAsync_WhenStoredDeviceIdIsMissingAndBehaviorIsReject_ReturnsDeviceMismatch(bool requestDeviceMissing) + { + var fixture = CreateFixture( + storedDevice: DeviceContext.Anonymous(), + requestDevice: requestDeviceMissing + ? DeviceContext.Anonymous() + : TestDevice.Default(), + deviceMismatchBehavior: DeviceMismatchBehavior.Reject); + + SetupValidAggregate(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.DeviceMismatch); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Fact] public async Task ValidateSessionAsync_PropagatesCancellationToken() { @@ -514,8 +596,8 @@ private static Fixture CreateFixture( DateTimeOffset? chainExpiresAt = null, TimeSpan? idleTimeout = null, DeviceContext? requestDevice = null, - DeviceMismatchBehavior deviceMismatchBehavior = - DeviceMismatchBehavior.Reject) + DeviceMismatchBehavior deviceMismatchBehavior = DeviceMismatchBehavior.Reject, + DeviceContext? storedDevice = null) { var tenant = TenantKey.Single; var user = UserKey.New(); @@ -532,7 +614,7 @@ private static Fixture CreateFixture( user, chainCreatedAt ?? Now.AddHours(-1), chainExpiresAt ?? Now.AddDays(7), - TestDevice.Default(), + storedDevice ?? TestDevice.Default(), ClaimsSnapshot.Empty, root.SecurityVersion); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs index a6b16ad8..c89a0ee3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/CompositeSessionIdResolverTests.cs @@ -1,350 +1,297 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +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 AwesomeAssertions; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; public sealed class CompositeSessionIdResolverTests { + private const UAuthClientProfile Profile = UAuthClientProfile.BlazorServer; + [Fact] - public void Resolve_ShouldUseFirstSuccessfulResolverAccordingToConfiguredOrder() + public async Task ResolveAsync_ShouldUseFirstSuccessfulResolverAccordingToConfiguredOrder() { - var firstId = - CreateSessionId( - "first-session-00000000000000000000000001"); - - var secondId = - CreateSessionId( - "second-session-0000000000000000000000001"); + var firstId = CreateSessionId("first-session-00000000000000000000000001"); + var secondId = CreateSessionId("second-session-0000000000000000000000001"); - var bearer = - CreateResolver( - "bearer", - firstId); + var bearer = CreateResolver("Bearer", firstId); + var cookie = CreateResolver("Cookie", secondId); - var cookie = - CreateResolver( - "cookie", - secondId); + var options = CreateOptions("Bearer", "Cookie"); - var options = TestServerOptions.Default(); + var (sut, context) = CreateSut( + options, + bearer.Object, + cookie.Object); - options.SessionResolution.Order = - new List { "Bearer", "Cookie" }; + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - options.SessionResolution.EnableBearer = true; - options.SessionResolution.EnableCookie = true; + result.Should().Be(firstId); + bearer.Verify(x => x.Resolve(context), Times.Once); + cookie.Verify(x => x.Resolve(context), Times.Never); + } - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object, - cookie.Object - }, - Options.Create(options)); + [Fact] + public async Task ResolveAsync_WhenFirstResolverReturnsNull_ShouldContinueToNextResolver() + { + var expected = CreateSessionId("cookie-session-0000000000000000000000002"); - var context = new DefaultHttpContext(); + var bearer = CreateResolver("Bearer", null); + var cookie = CreateResolver("Cookie", expected); - var result = sut.Resolve(context); + var options = CreateOptions("Bearer", "Cookie"); - result.Should().Be(firstId); + var (sut, context) = CreateSut( + options, + bearer.Object, + cookie.Object); - bearer.Verify( - x => x.Resolve(context), - Times.Once); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - cookie.Verify( - x => x.Resolve(context), - Times.Never); + result.Should().Be(expected); + bearer.Verify(x => x.Resolve(context), Times.Once); + cookie.Verify(x => x.Resolve(context), Times.Once); } [Fact] - public void Resolve_WhenFirstResolverReturnsNull_ShouldContinueToNextResolver() + public async Task ResolveAsync_WhenResolverIsDisabled_ShouldSkipIt() { - var expected = - CreateSessionId( - "cookie-session-0000000000000000000000002"); - - var bearer = - CreateResolver( - "bearer", - null); + var bearerId = CreateSessionId("bearer-session-0000000000000000000000002"); + var cookieId = CreateSessionId("cookie-session-0000000000000000000000003"); - var cookie = - CreateResolver( - "cookie", - expected); + var bearer = CreateResolver("Bearer", bearerId); + var cookie = CreateResolver("Cookie", cookieId); - var options = TestServerOptions.Default(); + var options = CreateOptions("Bearer", "Cookie"); + options.SessionResolution.EnableBearer = false; - options.SessionResolution.Order = - new List { "Bearer", "Cookie" }; + var (sut, context) = CreateSut( + options, + bearer.Object, + cookie.Object); - options.SessionResolution.EnableBearer = true; - options.SessionResolution.EnableCookie = true; + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object, - cookie.Object - }, - Options.Create(options)); + result.Should().Be(cookieId); + bearer.Verify(x => x.Resolve(It.IsAny()), Times.Never); + cookie.Verify(x => x.Resolve(context), Times.Once); + } - var context = new DefaultHttpContext(); + [Fact] + public async Task ResolveAsync_WhenConfiguredResolverDoesNotExist_ShouldContinue() + { + var expected = CreateSessionId("cookie-session-0000000000000000000000004"); - var result = sut.Resolve(context); + var cookie = CreateResolver("Cookie", expected); + var options = CreateOptions("Bearer", "Cookie"); - result.Should().Be(expected); + var (sut, context) = CreateSut(options, cookie.Object); - bearer.Verify( - x => x.Resolve(context), - Times.Once); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - cookie.Verify( - x => x.Resolve(context), - Times.Once); + result.Should().Be(expected); + cookie.Verify(x => x.Resolve(context), Times.Once); } [Fact] - public void Resolve_WhenResolverIsDisabled_ShouldSkipIt() + public async Task ResolveAsync_WhenOrderUsesDifferentCasing_ShouldResolveCaseInsensitively() { - var bearerId = - CreateSessionId( - "bearer-session-0000000000000000000000002"); + var expected = CreateSessionId("bearer-session-0000000000000000000000005"); - var cookieId = - CreateSessionId( - "cookie-session-0000000000000000000000003"); + var bearer = CreateResolver("bearer", expected); + var options = CreateOptions("bEaReR"); - var bearer = - CreateResolver( - "bearer", - bearerId); + var (sut, context) = CreateSut(options, bearer.Object); - var cookie = - CreateResolver( - "cookie", - cookieId); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - var options = TestServerOptions.Default(); - - options.SessionResolution.Order = - new List { "Bearer", "Cookie" }; - - options.SessionResolution.EnableBearer = false; - options.SessionResolution.EnableCookie = true; - - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object, - cookie.Object - }, - Options.Create(options)); + result.Should().Be(expected); + bearer.Verify(x => x.Resolve(context), Times.Once); + } - var context = new DefaultHttpContext(); + [Fact] + public async Task ResolveAsync_WhenNoResolverProducesSessionId_ShouldReturnNull() + { + var bearer = CreateResolver("Bearer", null); + var cookie = CreateResolver("Cookie", null); - var result = sut.Resolve(context); + var options = CreateOptions("Bearer", "Cookie"); - result.Should().Be(cookieId); + var (sut, context) = CreateSut( + options, + bearer.Object, + cookie.Object); - bearer.Verify( - x => x.Resolve( - It.IsAny()), - Times.Never); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - cookie.Verify( - x => x.Resolve(context), - Times.Once); + result.Should().BeNull(); + bearer.Verify(x => x.Resolve(context), Times.Once); + cookie.Verify(x => x.Resolve(context), Times.Once); } [Fact] - public void Resolve_WhenConfiguredResolverDoesNotExist_ShouldContinue() + public async Task ResolveAsync_WhenOrderChanges_ShouldRespectNewPrecedence() { - var expected = - CreateSessionId( - "cookie-session-0000000000000000000000004"); + var bearerId = CreateSessionId("bearer-session-0000000000000000000000006"); + var cookieId = CreateSessionId("cookie-session-0000000000000000000000006"); - var cookie = - CreateResolver( - "cookie", - expected); + var bearer = CreateResolver("Bearer", bearerId); + var cookie = CreateResolver("Cookie", cookieId); - var options = TestServerOptions.Default(); - - options.SessionResolution.Order = - new List { "Bearer", "Cookie" }; - - options.SessionResolution.EnableBearer = true; - options.SessionResolution.EnableCookie = true; + var options = CreateOptions("Cookie", "Bearer"); - var sut = - new CompositeSessionIdResolver( - new[] - { - cookie.Object - }, - Options.Create(options)); + var (sut, context) = CreateSut( + options, + bearer.Object, + cookie.Object); - var result = - sut.Resolve( - new DefaultHttpContext()); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - result.Should().Be(expected); + result.Should().Be(cookieId); + cookie.Verify(x => x.Resolve(context), Times.Once); + bearer.Verify(x => x.Resolve(context), Times.Never); } - [Fact] - public void Resolve_WhenOrderUsesDifferentCasing_ShouldResolveCaseInsensitively() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAsync_ShouldRespectEffectiveCookieSetting( + bool effectiveCookieEnabled) { - var expected = - CreateSessionId( - "bearer-session-0000000000000000000000005"); - - var bearer = - CreateResolver( - "bearer", - expected); - - var options = TestServerOptions.Default(); + var expected = CreateSessionId("cookie-session-000000000000000000000001"); - options.SessionResolution.Order = - new List { "bEaReR" }; + var cookie = CreateResolver("Cookie", expected); - options.SessionResolution.EnableBearer = true; + var options = CreateOptions("Cookie"); + options.SessionResolution.EnableCookie = effectiveCookieEnabled; - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object - }, - Options.Create(options)); + var (sut, context) = CreateSut(options, cookie.Object); - var result = - sut.Resolve( - new DefaultHttpContext()); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - result.Should().Be(expected); + if (effectiveCookieEnabled) + { + result.Should().Be(expected); + cookie.Verify(x => x.Resolve(context), Times.Once); + } + else + { + result.Should().BeNull(); + cookie.Verify(x => x.Resolve(context), Times.Never); + } } [Fact] - public void Resolve_WhenNoResolverProducesSessionId_ShouldReturnNull() + public async Task ResolveAsync_ShouldReadClientProfileAndUseItsEffectiveOptions() { - var bearer = - CreateResolver( - "bearer", - null); + var expected = CreateSessionId("cookie-session-000000000000000000000002"); - var cookie = - CreateResolver( - "cookie", - null); + var cookie = CreateResolver("Cookie", expected); - var options = TestServerOptions.Default(); + var options = CreateOptions("Cookie"); - options.SessionResolution.Order = - new List { "Bearer", "Cookie" }; + var profileReader = new Mock(); + var effectiveProvider = new Mock(); - options.SessionResolution.EnableBearer = true; - options.SessionResolution.EnableCookie = true; + var context = new DefaultHttpContext(); - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object, - cookie.Object - }, - Options.Create(options)); + profileReader + .Setup(x => x.ReadAsync(context)) + .ReturnsAsync(Profile); - var result = - sut.Resolve( - new DefaultHttpContext()); + effectiveProvider + .Setup(x => x.GetEffective(context, Profile)) + .Returns(new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = options + }); - result.Should().BeNull(); - } + var sut = new CompositeSessionIdResolver( + new[] { cookie.Object }, + profileReader.Object, + effectiveProvider.Object); - [Fact] - public void Resolve_WhenOrderChanges_ShouldRespectNewPrecedence() - { - var bearerId = - CreateSessionId( - "bearer-session-0000000000000000000000006"); + var result = await sut.ResolveAsync(context, ct: TestContext.Current.CancellationToken); - var cookieId = - CreateSessionId( - "cookie-session-0000000000000000000000006"); + result.Should().Be(expected); - var bearer = - CreateResolver( - "bearer", - bearerId); + profileReader.Verify( + x => x.ReadAsync(context), + Times.Once); - var cookie = - CreateResolver( - "cookie", - cookieId); + effectiveProvider.Verify( + x => x.GetEffective(context, Profile), + Times.Once); - var options = TestServerOptions.Default(); + cookie.Verify( + x => x.Resolve(context), + Times.Once); + } - options.SessionResolution.Order = - new List { "Cookie", "Bearer" }; + private static UAuthServerOptions CreateOptions(params string[] order) + { + var options = TestServerOptions.Default(); + options.SessionResolution.Order = order.ToList(); options.SessionResolution.EnableBearer = true; + options.SessionResolution.EnableHeader = true; options.SessionResolution.EnableCookie = true; + options.SessionResolution.EnableQuery = true; - var sut = - new CompositeSessionIdResolver( - new[] - { - bearer.Object, - cookie.Object - }, - Options.Create(options)); + return options; + } - var result = - sut.Resolve( - new DefaultHttpContext()); + private static (CompositeSessionIdResolver Sut, DefaultHttpContext Context) + CreateSut( + UAuthServerOptions effectiveOptions, + params IInnerSessionIdResolver[] resolvers) + { + var context = new DefaultHttpContext(); - result.Should().Be(cookieId); + var profileReader = new Mock(); + profileReader + .Setup(x => x.ReadAsync(context)) + .ReturnsAsync(Profile); + + var effectiveProvider = new Mock(); + effectiveProvider + .Setup(x => x.GetEffective(context, Profile)) + .Returns(new EffectiveUAuthServerOptions + { + Mode = UAuthMode.PureOpaque, + Options = effectiveOptions + }); + + var sut = new CompositeSessionIdResolver( + resolvers, + profileReader.Object, + effectiveProvider.Object); + + return (sut, context); } - private static Mock CreateResolver( - string name, - AuthSessionId? result) + private static Mock CreateResolver(string name, AuthSessionId? result) { - var resolver = - new Mock(); + var resolver = new Mock(); - resolver - .SetupGet(x => x.Name) - .Returns(name); + resolver.SetupGet(x => x.Name).Returns(name); - resolver - .Setup(x => x.Resolve( - It.IsAny())) - .Returns(result); + resolver.Setup(x => x.Resolve(It.IsAny())).Returns(result); return resolver; } - private static AuthSessionId CreateSessionId( - string value) + private static AuthSessionId CreateSessionId(string value) { - AuthSessionId.TryCreate( - value, - out var id) - .Should() - .BeTrue(); - + AuthSessionId.TryCreate(value, out var id).Should().BeTrue(); return id; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs index cd433dba..c26fe638 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -26,9 +26,9 @@ public async Task Login_should_cleanup_old_sessions_when_limit_exceeded() } var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); - var chains = await store.GetChainsByUserAsync(TestUsers.User); + var chains = await store.GetChainsByUserAsync(TestUsers.User, ct: TestContext.Current.CancellationToken); var chain = chains.First(); - var sessions = await store.GetSessionsByChainAsync(chain.ChainId); + var sessions = await store.GetSessionsByChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); sessions.Count.Should().BeLessThanOrEqualTo(3); } @@ -44,17 +44,17 @@ public async Task Logout_device_should_revoke_sessions_but_keep_chain() { Identifier = "user", Secret = "user" - }); + }, ct: TestContext.Current.CancellationToken); 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); + var chainId = await store.GetChainIdBySessionAsync(result.SessionId!.Value, ct: TestContext.Current.CancellationToken); + await store.LogoutChainAsync(chainId!.Value, runtime.Clock.UtcNow, ct: TestContext.Current.CancellationToken); + var chain = await store.GetChainAsync(chainId.Value, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); chain!.ActiveSessionId.Should().BeNull(); - var sessions = await store.GetSessionsByChainAsync(chainId.Value); + var sessions = await store.GetSessionsByChainAsync(chainId.Value, ct: TestContext.Current.CancellationToken); sessions.All(x => x.IsRevoked).Should().BeTrue(); } @@ -74,13 +74,13 @@ public async Task Logout_other_devices_should_keep_current_chain() var store = runtime.Services.GetRequiredService() .Create(TenantKey.Single); - var chains = await store.GetChainsByUserAsync(TestUsers.User); + var chains = await store.GetChainsByUserAsync(TestUsers.User, ct: TestContext.Current.CancellationToken); var current = chains.First(); - await store.RevokeOtherSessionsAsync(current.UserKey, current.ChainId, runtime.Clock.UtcNow); + await store.RevokeOtherSessionsAsync(current.UserKey, current.ChainId, runtime.Clock.UtcNow, ct: TestContext.Current.CancellationToken); - var updatedChains = await store.GetChainsByUserAsync(current.UserKey); + var updatedChains = await store.GetChainsByUserAsync(current.UserKey, ct: TestContext.Current.CancellationToken); updatedChains.Count(x => x.State == SessionChainState.Active).Should().Be(1); } @@ -97,13 +97,13 @@ public async Task Get_chain_detail_should_return_sessions() { Identifier = "user", Secret = "user" - }); + }, ct: TestContext.Current.CancellationToken); 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); + var chains = await store.GetChainsByUserAsync(TestUsers.User, ct: TestContext.Current.CancellationToken); + var result = await service.GetUserChainDetailAsync(context, chains.First().UserKey, chains.First().ChainId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.Sessions.Should().NotBeEmpty(); @@ -121,12 +121,12 @@ public async Task Revoke_chain_should_revoke_all_sessions() { Identifier = "user", Secret = "user" - }); + }, ct: TestContext.Current.CancellationToken); 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); + var chains = await store.GetChainsByUserAsync(TestUsers.User, ct: TestContext.Current.CancellationToken); + await store.RevokeChainCascadeAsync(chains.First().ChainId, runtime.Clock.UtcNow, ct: TestContext.Current.CancellationToken); + var sessions = await store.GetSessionsByChainAsync(chains.First().ChainId, ct: TestContext.Current.CancellationToken); sessions.All(x => x.IsRevoked).Should().BeTrue(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs index 919b77dc..d3a3ed79 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs @@ -4,8 +4,7 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; -public sealed class InMemorySessionStoreChainContractTests - : SessionStoreChainContractTests +public sealed class InMemorySessionStoreChainContractTests : SessionStoreChainContractTests { protected override Task CreateDatabaseAsync() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs index ef08049b..4b632fba 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs @@ -83,7 +83,7 @@ public async Task CreateChainAsync_PersistsChain() await CreateChainGraphAsync(store, Tenant); var result = - await store.GetChainAsync(chain.ChainId); + await store.GetChainAsync(chain.ChainId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -107,7 +107,7 @@ public async Task GetChainAsync_WhenMissing_ReturnsNull() var result = await store.GetChainAsync( - SessionChainId.New()); + SessionChainId.New(), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -126,7 +126,7 @@ public async Task CreateChainAsync_WhenVersionIsNotZero_IsRejected() Now); await store.ExecuteAsync( - ct => store.CreateRootAsync(root, ct)); + ct => store.CreateRootAsync(root, ct), ct: TestContext.Current.CancellationToken); var chain = CreateChain( root, @@ -196,11 +196,11 @@ await store.ExecuteAsync( ct => store.SaveChainAsync( updated, expectedVersion: chain.Version, - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetChainAsync( - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.LastSeenAt.Should().Be( @@ -283,11 +283,11 @@ await store.ExecuteAsync( ct => store.RevokeChainAsync( chain.ChainId, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetChainAsync( - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsRevoked.Should().BeTrue(); @@ -326,17 +326,17 @@ await store.ExecuteAsync( ct => store.RevokeChainAsync( chain.ChainId, Now.AddMinutes(5), - ct)); + ct), ct: TestContext.Current.CancellationToken); await store.ExecuteAsync( ct => store.RevokeChainAsync( chain.ChainId, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetChainAsync( - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.RevokedAt.Should().Be( @@ -372,7 +372,7 @@ await CreateChainGraphAsync( await store.ExecuteAsync( ct => store.CreateChainAsync( secondChainA, - ct)); + ct), ct: TestContext.Current.CancellationToken); var (_, chainB) = await CreateChainGraphAsync( @@ -382,7 +382,7 @@ await CreateChainGraphAsync( var result = await store.GetChainsByRootAsync( - rootA.RootId); + rootA.RootId, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); @@ -421,10 +421,10 @@ await CreateChainGraphAsync( await store.ExecuteAsync( ct => store.CreateChainAsync( second, - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = - await store.GetChainsByUserAsync(user); + await store.GetChainsByUserAsync(user, ct: TestContext.Current.CancellationToken); result.Select(x => x.ChainId) .Should() @@ -459,11 +459,11 @@ await CreateChainGraphAsync( var fromA = await storeA.GetChainAsync( - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetChainAsync( - chain.ChainId); + chain.ChainId, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs index 9d6eca75..3dccddd6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs @@ -230,13 +230,13 @@ public async Task Graph_HasExpectedActiveSessions() var graph = await CreateGraphAsync(store); var chainA1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); var chainA2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); chainA1!.ActiveSessionId .Should().Be(graph.SessionA12.SessionId); @@ -266,10 +266,10 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( graph.SessionA11.SessionId, revokedAt, - ct)); + ct), ct: TestContext.Current.CancellationToken); var session = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); session.Should().NotBeNull(); session!.IsRevoked.Should().BeTrue(); @@ -288,10 +288,10 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( graph.SessionA11.SessionId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chain!.ActiveSessionId .Should().Be(graph.SessionA12.SessionId); @@ -309,13 +309,13 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( graph.SessionA12.SessionId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var session = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); session.Should().NotBeNull(); session!.IsRevoked.Should().BeTrue(); @@ -337,13 +337,13 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( graph.SessionA12.SessionId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); var b11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); a21!.IsRevoked.Should().BeFalse(); b11!.IsRevoked.Should().BeFalse(); @@ -364,10 +364,10 @@ public async Task RemoveSessionAsync_WhenSessionIsNotActive_RemovesSession() await store.ExecuteAsync( ct => store.RemoveSessionAsync( graph.SessionA11.SessionId, - ct)); + ct), ct: TestContext.Current.CancellationToken); var session = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); session.Should().BeNull(); } @@ -383,10 +383,10 @@ public async Task RemoveSessionAsync_WhenSessionIsNotActive_PreservesActiveSessi await store.ExecuteAsync( ct => store.RemoveSessionAsync( graph.SessionA11.SessionId, - ct)); + ct), ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chain!.ActiveSessionId .Should().Be(graph.SessionA12.SessionId); @@ -403,13 +403,13 @@ public async Task RemoveSessionAsync_WhenSessionIsActive_DoesNotLeaveDanglingRef await store.ExecuteAsync( ct => store.RemoveSessionAsync( graph.SessionA12.SessionId, - ct)); + ct), ct: TestContext.Current.CancellationToken); var session = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); session.Should().BeNull(); @@ -436,13 +436,13 @@ await store.ExecuteAsync( ct => store.LogoutChainAsync( graph.ChainA1.ChainId, at, - ct)); + ct), ct: TestContext.Current.CancellationToken); var a11 = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); var a12 = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); a11!.IsRevoked.Should().BeTrue(); a12!.IsRevoked.Should().BeTrue(); @@ -463,10 +463,10 @@ await store.ExecuteAsync( ct => store.LogoutChainAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); chain!.ActiveSessionId.Should().BeNull(); @@ -484,10 +484,10 @@ await store.ExecuteAsync( ct => store.LogoutChainAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); chain!.IsRevoked.Should().BeFalse(); @@ -505,19 +505,19 @@ await store.ExecuteAsync( ct => store.LogoutChainAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chainA2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); var sessionA21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); var sessionB11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); chainA2!.IsRevoked.Should().BeFalse(); chainA2.ActiveSessionId.Should().Be( @@ -550,10 +550,10 @@ await store.ExecuteAsync( ct => store.RevokeChainCascadeAsync( graph.ChainA1.ChainId, at, - ct)); + ct), ct: TestContext.Current.CancellationToken); var chain = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chain.Should().NotBeNull(); chain!.IsRevoked.Should().BeTrue(); @@ -572,13 +572,13 @@ await store.ExecuteAsync( ct => store.RevokeChainCascadeAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a11 = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); var a12 = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); a11!.IsRevoked.Should().BeTrue(); a12!.IsRevoked.Should().BeTrue(); @@ -596,19 +596,19 @@ await store.ExecuteAsync( ct => store.RevokeChainCascadeAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chainA2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); var sessionA21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); var sessionB11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); chainA2!.IsRevoked.Should().BeFalse(); sessionA21!.IsRevoked.Should().BeFalse(); @@ -633,16 +633,16 @@ await store.ExecuteAsync( ct => store.RevokeAllSessionsAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a11 = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); var a12 = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); var a21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); a11!.IsRevoked.Should().BeTrue(); a12!.IsRevoked.Should().BeTrue(); @@ -661,13 +661,13 @@ await store.ExecuteAsync( ct => store.RevokeAllSessionsAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chainA1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); var chainA2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); chainA1!.ActiveSessionId.Should().BeNull(); chainA2!.ActiveSessionId.Should().BeNull(); @@ -685,13 +685,13 @@ await store.ExecuteAsync( ct => store.RevokeAllSessionsAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); var sessionB11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); chainB1!.IsRevoked.Should().BeFalse(); @@ -722,19 +722,19 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a11 = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); var a12 = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); a11!.IsRevoked.Should().BeFalse(); a12!.IsRevoked.Should().BeFalse(); var chainA1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); chainA1!.ActiveSessionId .Should().Be(graph.SessionA12.SessionId); @@ -753,10 +753,10 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); a21.Should().NotBeNull(); a21!.IsRevoked.Should().BeTrue(); @@ -775,13 +775,13 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var kept = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); var affected = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); kept!.ActiveSessionId .Should().Be(graph.SessionA12.SessionId); @@ -803,13 +803,13 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var b11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); b11!.IsRevoked.Should().BeFalse(); @@ -836,13 +836,13 @@ await store.ExecuteAsync( ct => store.RevokeAllChainsAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); var a2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); a1!.IsRevoked.Should().BeTrue(); a2!.IsRevoked.Should().BeTrue(); @@ -860,10 +860,10 @@ await store.ExecuteAsync( ct => store.RevokeAllChainsAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var b1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); b1!.IsRevoked.Should().BeFalse(); } @@ -885,10 +885,10 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); a1.Should().NotBeNull(); a1!.IsRevoked.Should().BeFalse(); @@ -907,10 +907,10 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); a2.Should().NotBeNull(); a2!.IsRevoked.Should().BeTrue(); @@ -929,10 +929,10 @@ await store.ExecuteAsync( graph.UserA, graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var b1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); b1!.IsRevoked.Should().BeFalse(); } @@ -955,17 +955,17 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, at, - ct)); + ct), ct: TestContext.Current.CancellationToken); var activeRoot = await store.GetActiveRootByUserAsync( - graph.UserA); + graph.UserA, ct: TestContext.Current.CancellationToken); activeRoot.Should().BeNull(); var root = await store.GetRootByIdAsync( - graph.RootA.RootId); + graph.RootA.RootId, ct: TestContext.Current.CancellationToken); root.Should().NotBeNull(); @@ -985,17 +985,17 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var activeRoot = await store.GetActiveRootByUserAsync( - graph.UserA); + graph.UserA, ct: TestContext.Current.CancellationToken); activeRoot.Should().BeNull(); var root = await store.GetRootByIdAsync( - graph.RootA.RootId); + graph.RootA.RootId, ct: TestContext.Current.CancellationToken); root.Should().NotBeNull(); @@ -1018,13 +1018,13 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a1 = await store.GetChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); var a2 = await store.GetChainAsync( - graph.ChainA2.ChainId); + graph.ChainA2.ChainId, ct: TestContext.Current.CancellationToken); a1!.IsRevoked.Should().BeTrue(); a2!.IsRevoked.Should().BeTrue(); @@ -1042,16 +1042,16 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var a11 = await store.GetSessionAsync( - graph.SessionA11.SessionId); + graph.SessionA11.SessionId, ct: TestContext.Current.CancellationToken); var a12 = await store.GetSessionAsync( - graph.SessionA12.SessionId); + graph.SessionA12.SessionId, ct: TestContext.Current.CancellationToken); var a21 = await store.GetSessionAsync( - graph.SessionA21.SessionId); + graph.SessionA21.SessionId, ct: TestContext.Current.CancellationToken); a11!.IsRevoked.Should().BeTrue(); a12!.IsRevoked.Should().BeTrue(); @@ -1070,16 +1070,16 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var rootB = await store.GetActiveRootByUserAsync( - graph.UserB); + graph.UserB, ct: TestContext.Current.CancellationToken); var chainB1 = await store.GetChainAsync( - graph.ChainB1.ChainId); + graph.ChainB1.ChainId, ct: TestContext.Current.CancellationToken); var sessionB11 = await store.GetSessionAsync( - graph.SessionB11.SessionId); + graph.SessionB11.SessionId, ct: TestContext.Current.CancellationToken); rootB.Should().NotBeNull(); chainB1.Should().NotBeNull(); @@ -1109,10 +1109,10 @@ await store.ExecuteAsync( ct => store.LogoutChainAsync( graph.ChainA1.ChainId, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var sessions = await store.GetSessionsByChainAsync( - graph.ChainA1.ChainId); + graph.ChainA1.ChainId, ct: TestContext.Current.CancellationToken); sessions.Should().HaveCount(2); sessions.Should().OnlyContain(x => x.IsRevoked); @@ -1130,11 +1130,11 @@ await store.ExecuteAsync( ct => store.RevokeRootCascadeAsync( graph.UserA, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var chains = await store.GetChainsByUserAsync( graph.UserA, - includeHistoricalRoots: true); + includeHistoricalRoots: true, ct: TestContext.Current.CancellationToken); chains.Should().HaveCount(2); chains.Should().OnlyContain(x => x.IsRevoked); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs index 91a75392..4f2fc72e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs @@ -50,10 +50,10 @@ public async Task CreateRootAsync_PersistsRoot() Now); await store.ExecuteAsync( - ct => store.CreateRootAsync(root, ct)); + ct => store.CreateRootAsync(root, ct), ct: TestContext.Current.CancellationToken); var result = - await store.GetActiveRootByUserAsync(user); + await store.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -81,7 +81,7 @@ public async Task GetRootByUserAsync_WhenMissing_ReturnsNull() var result = await store.GetActiveRootByUserAsync( - UserKey.New()); + UserKey.New(), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -99,7 +99,7 @@ await CreateRootAsync( var result = await store.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -122,7 +122,7 @@ public async Task GetRootByIdAsync_WhenMissing_ReturnsNull() var result = await store.GetRootByIdAsync( - missingRoot.RootId); + missingRoot.RootId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -205,11 +205,11 @@ await store.ExecuteAsync( ct => store.SaveRootAsync( updated, expectedVersion: root.Version, - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetActiveRootByUserAsync( - root.UserKey); + root.UserKey, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -302,19 +302,19 @@ await store.ExecuteAsync( ct => store.RevokeRootAsync( root.UserKey, revokedAt, - ct)); + ct), ct: TestContext.Current.CancellationToken); // Revoked root is no longer active. var active = await store.GetActiveRootByUserAsync( - root.UserKey); + root.UserKey, ct: TestContext.Current.CancellationToken); active.Should().BeNull(); // But the historical root must still exist. var result = await store.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -364,11 +364,11 @@ await store.ExecuteAsync( ct => store.RevokeRootAsync( root.UserKey, firstRevokedAt, - ct)); + ct), ct: TestContext.Current.CancellationToken); var afterFirst = await store.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); afterFirst.Should().NotBeNull(); afterFirst!.IsRevoked.Should().BeTrue(); @@ -377,11 +377,11 @@ await store.ExecuteAsync( ct => store.RevokeRootAsync( root.UserKey, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); var afterSecond = await store.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); afterSecond.Should().NotBeNull(); afterSecond!.IsRevoked.Should().BeTrue(); @@ -428,10 +428,10 @@ await CreateRootAsync( user); var fromA = - await storeA.GetActiveRootByUserAsync(user); + await storeA.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); var fromB = - await storeB.GetActiveRootByUserAsync(user); + await storeB.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromA!.RootId.Should().Be(root.RootId); @@ -460,11 +460,11 @@ await CreateRootAsync( var fromA = await storeA.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetRootByIdAsync( - root.RootId); + root.RootId, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); @@ -554,7 +554,7 @@ await store.RevokeRootCascadeAsync( user, Now.AddMinutes(1), ct); - }); + }, ct: TestContext.Current.CancellationToken); var secondRoot = UAuthSessionRoot.Create( Tenant, @@ -566,17 +566,17 @@ await store.ExecuteAsync(async ct => await store.CreateRootAsync( secondRoot, ct); - }); + }, ct: TestContext.Current.CancellationToken); var active = - await store.GetActiveRootByUserAsync(user); + await store.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); active.Should().NotBeNull(); active!.RootId.Should().Be(secondRoot.RootId); active.IsRevoked.Should().BeFalse(); var historical = - await store.GetRootByIdAsync(firstRoot.RootId); + await store.GetRootByIdAsync(firstRoot.RootId, ct: TestContext.Current.CancellationToken); historical.Should().NotBeNull(); historical!.IsRevoked.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs index f0323c8d..03ba7bf2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs @@ -119,7 +119,7 @@ await CreateSessionGraphAsync( Tenant); var result = await store.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -141,7 +141,7 @@ public async Task GetSessionAsync_WhenSessionDoesNotExist_ReturnsNull() var store = db.CreateStore(Tenant); var result = await store.GetSessionAsync( - NewSessionId()); + NewSessionId(), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -221,11 +221,11 @@ await store.ExecuteAsync( ct => store.SaveSessionAsync( updated, expectedVersion: session.Version, - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsRevoked.Should().BeTrue(); @@ -306,12 +306,12 @@ await store.ExecuteAsync(async ct => session.SessionId, Now.AddMinutes(10), ct); - }); + }, ct: TestContext.Current.CancellationToken); revoked.Should().BeTrue(); var result = await store.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.IsRevoked.Should().BeTrue(); @@ -333,7 +333,7 @@ await store.ExecuteAsync(async ct => NewSessionId(), Now, ct); - }); + }, ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); } @@ -353,7 +353,7 @@ await store.ExecuteAsync( ct => store.RevokeSessionAsync( session.SessionId, Now.AddMinutes(5), - ct)); + ct), ct: TestContext.Current.CancellationToken); bool secondResult = true; @@ -364,13 +364,13 @@ await store.RevokeSessionAsync( session.SessionId, Now.AddMinutes(10), ct); - }); + }, ct: TestContext.Current.CancellationToken); secondResult.Should().BeFalse(); var result = await store.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.RevokedAt.Should().Be( @@ -396,11 +396,11 @@ await CreateSessionGraphAsync( await store.ExecuteAsync( ct => store.RemoveSessionAsync( session.SessionId, - ct)); + ct), ct: TestContext.Current.CancellationToken); var result = await store.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -437,7 +437,7 @@ await CreateSessionGraphAsync( var result = await store.GetChainIdBySessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); result.Should().Be(chain.ChainId); } @@ -449,7 +449,7 @@ public async Task GetChainIdBySessionAsync_WhenMissing_ReturnsNull() var store = db.CreateStore(Tenant); var result = await store.GetChainIdBySessionAsync( - NewSessionId()); + NewSessionId(), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -474,10 +474,10 @@ await CreateSessionGraphAsync( chainA.ChainId); await store.ExecuteAsync( - ct => store.CreateSessionAsync(a2, ct)); + ct => store.CreateSessionAsync(a2, ct), ct: TestContext.Current.CancellationToken); var root = - await store.GetActiveRootByUserAsync(user); + await store.GetActiveRootByUserAsync(user, ct: TestContext.Current.CancellationToken); root.Should().NotBeNull(); @@ -493,7 +493,7 @@ await store.ExecuteAsync( root.SecurityVersion); await store.ExecuteAsync( - ct => store.CreateChainAsync(chainB, ct)); + ct => store.CreateChainAsync(chainB, ct), ct: TestContext.Current.CancellationToken); var b1 = CreateSession( Tenant, @@ -501,11 +501,11 @@ await store.ExecuteAsync( chainB.ChainId); await store.ExecuteAsync( - ct => store.CreateSessionAsync(b1, ct)); + ct => store.CreateSessionAsync(b1, ct), ct: TestContext.Current.CancellationToken); var result = await store.GetSessionsByChainAsync( - chainA.ChainId); + chainA.ChainId, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); @@ -542,11 +542,11 @@ await CreateSessionGraphAsync( var fromA = await storeA.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); var fromB = await storeB.GetSessionAsync( - session.SessionId); + session.SessionId, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs index 04224572..badf5f7c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs @@ -103,7 +103,7 @@ public async Task IssueSessionAsync_WhenActiveRootDoesNotExist_ShouldCreateRoot( SetupEmptySessions(fixture); SetupSessionAndChainPersistence(fixture); - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); createdRoot.Should().NotBeNull(); createdRoot!.Tenant.Should().Be(context.Tenant); @@ -136,7 +136,7 @@ public async Task IssueSessionAsync_WhenActiveRootExists_ShouldReuseRoot() SetupSessionAndChainPersistence(fixture); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.Session.Should().NotBeNull(); @@ -178,7 +178,7 @@ public async Task IssueSessionAsync_WhenChainIdIsNotProvided_ShouldCreateNewChai SetupChainSave(fixture); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); createdChain.Should().NotBeNull(); @@ -227,7 +227,7 @@ public async Task IssueSessionAsync_WhenExistingChainIsProvided_ShouldReuseChain SetupChainSave(fixture); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.Session.ChainId.Should() .Be(chain.ChainId); @@ -279,7 +279,7 @@ public async Task IssueSessionAsync_WhenExplicitChainDoesNotExist_CurrentlyCreat SetupChainSave(fixture); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); createdChain.Should().NotBeNull(); @@ -429,7 +429,7 @@ public async Task IssueSessionAsync_ShouldUseConfiguredSessionLifetime() SetupNewIssuance(fixture, context); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.Session.ExpiresAt.Should() .Be(Now.AddHours(4)); @@ -447,7 +447,7 @@ public async Task IssueSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapExpiration SetupNewIssuance(fixture, context); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.Session.ExpiresAt.Should() .Be(Now.AddHours(2)); @@ -465,7 +465,7 @@ public async Task IssueSessionAsync_WhenMaxLifetimeIsLonger_ShouldUseNormalLifet SetupNewIssuance(fixture, context); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.Session.ExpiresAt.Should() .Be(Now.AddHours(4)); @@ -520,7 +520,7 @@ public async Task IssueSessionAsync_ShouldPersistSessionAndAttachItToChain() .Returns(Task.CompletedTask); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); createdSession.Should().NotBeNull(); savedChain.Should().NotBeNull(); @@ -546,7 +546,7 @@ public async Task IssueSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMetad SetupNewIssuance(fixture, context); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.IsMetadataOnly.Should().BeTrue(); } @@ -562,7 +562,7 @@ public async Task IssueSessionAsync_WhenModeIsHybrid_ShouldNotMarkResultAsMetada SetupNewIssuance(fixture, context); var result = - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); result.IsMetadataOnly.Should().BeFalse(); } @@ -638,7 +638,7 @@ public async Task IssueSessionAsync_WhenSessionLimitReached_ShouldRemoveOldNonAc (id, _) => removed.Add(id)) .Returns(Task.CompletedTask); - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); removed.Should().HaveCount(2); @@ -662,7 +662,7 @@ public async Task IssueSessionAsync_WhenBelowSessionLimit_ShouldNotRemoveSession SetupNewIssuance(fixture, context); - await fixture.Sut.IssueSessionAsync(context); + await fixture.Sut.IssueSessionAsync(context, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RemoveSessionAsync( @@ -1060,7 +1060,7 @@ public async Task RotateSessionAsync_ShouldCreateNewSessionOnSameChain() .Returns(Task.CompletedTask); var result = - await fixture.Sut.RotateSessionAsync(context); + await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); persistedSession.Should().NotBeNull(); @@ -1096,7 +1096,7 @@ public async Task RotateSessionAsync_ShouldRotateChainActiveSession() (chain, _, _) => savedChain = chain) .Returns(Task.CompletedTask); - await fixture.Sut.RotateSessionAsync(context); + await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); savedChain.Should().NotBeNull(); @@ -1134,7 +1134,7 @@ public async Task RotateSessionAsync_ShouldRevokeOldSession() }) .Returns(Task.CompletedTask); - await fixture.Sut.RotateSessionAsync(context); + await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); savedOldSession.Should().NotBeNull(); savedOldSession!.IsRevoked.Should().BeTrue(); @@ -1165,7 +1165,7 @@ public async Task RotateSessionAsync_ShouldReturnSameBoundSessionThatWasPersiste persistedSession = session) .Returns(Task.CompletedTask); - var result = await fixture.Sut.RotateSessionAsync(context); + var result = await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); persistedSession.Should().NotBeNull(); persistedSession!.ChainId.Should().Be(setup.Chain.ChainId); @@ -1187,7 +1187,7 @@ public async Task RotateSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMeta context); var result = - await fixture.Sut.RotateSessionAsync(context); + await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); result.IsMetadataOnly.Should().BeTrue(); } @@ -1215,7 +1215,7 @@ public async Task RotateSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapNewSessio (session, _) => persisted = session) .Returns(Task.CompletedTask); - await fixture.Sut.RotateSessionAsync(context); + await fixture.Sut.RotateSessionAsync(context, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -1246,7 +1246,7 @@ public async Task RevokeSessionAsync_ShouldDelegateToTenantStoreAndReturnResult( await fixture.Sut.RevokeSessionAsync( Tenant, sessionId, - Now); + Now, ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); @@ -1274,7 +1274,7 @@ public async Task RevokeChainAsync_WhenChainDoesNotExist_ShouldDoNothing() await fixture.Sut.RevokeChainAsync( Tenant, chainId, - Now); + Now, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RevokeChainCascadeAsync( @@ -1305,7 +1305,7 @@ public async Task RevokeChainAsync_WhenChainExists_ShouldCascadeRevoke() await fixture.Sut.RevokeChainAsync( Tenant, chain.ChainId, - Now); + Now, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RevokeChainCascadeAsync( @@ -1348,7 +1348,7 @@ await fixture.Sut.RevokeAllChainsAsync( Tenant, user, exceptChainId: null, - Now); + Now, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RevokeChainCascadeAsync( @@ -1394,7 +1394,7 @@ await fixture.Sut.RevokeAllChainsAsync( Tenant, user, keep.ChainId, - Now); + Now, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RevokeChainCascadeAsync( @@ -1431,7 +1431,7 @@ public async Task RevokeRootAsync_ShouldCascadeRevokeRoot() await fixture.Sut.RevokeRootAsync( Tenant, user, - Now); + Now, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.RevokeRootCascadeAsync( @@ -1465,7 +1465,7 @@ public async Task GetChainIdBySessionAsync_ShouldReturnStoreResult() var result = await fixture.Sut.GetChainIdBySessionAsync( Tenant, - sessionId); + sessionId, ct: TestContext.Current.CancellationToken); result.Should().Be(chainId); } @@ -1490,7 +1490,7 @@ public async Task LogoutChainAsync_WhenChainDoesNotExist_ShouldReturnFalse() await fixture.Sut.LogoutChainAsync( Tenant, chainId, - Now); + Now, ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); @@ -1522,7 +1522,7 @@ public async Task LogoutChainAsync_WhenChainIsRevoked_ShouldReturnFalse() await fixture.Sut.LogoutChainAsync( Tenant, chain.ChainId, - Now); + Now, ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); @@ -1556,7 +1556,7 @@ public async Task LogoutChainAsync_WhenChainIsActive_ShouldLogoutAndReturnTrue() await fixture.Sut.LogoutChainAsync( Tenant, chain.ChainId, - Now); + Now, ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs index bf95ebf9..ff3ede7d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs @@ -35,10 +35,10 @@ public async Task StoreAsync_PersistsToken() "hash-a11"); await store.ExecuteAsync( - ct => store.StoreAsync(token, ct)); + ct => store.StoreAsync(token, ct), ct: TestContext.Current.CancellationToken); var persisted = await store.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -60,7 +60,7 @@ public async Task FindByHashAsync_WhenMissing_ReturnsNull() var store = db.CreateStore(TenantA); var result = await store.FindByHashAsync( - "missing-token-hash"); + "missing-token-hash", ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -95,13 +95,13 @@ public async Task FindByHashAsync_IsTenantIsolated() "tenant-isolation-token"); await storeA.ExecuteAsync( - ct => storeA.StoreAsync(token, ct)); + ct => storeA.StoreAsync(token, ct), ct: TestContext.Current.CancellationToken); var fromA = await storeA.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); var fromB = await storeB.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); fromA.Should().NotBeNull(); fromB.Should().BeNull(); @@ -129,10 +129,10 @@ await store.ExecuteAsync( ct => store.RevokeAsync( token.TokenHash, revokedAt, - ct: ct)); + ct: ct), ct: TestContext.Current.CancellationToken); var persisted = await store.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -159,10 +159,10 @@ await store.ExecuteAsync( token.TokenHash, revokedAt, "rotation-new", - ct)); + ct), ct: TestContext.Current.CancellationToken); var persisted = await store.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -206,17 +206,17 @@ await store.ExecuteAsync( token.TokenHash, firstRevocation, "replacement-1", - ct)); + ct), ct: TestContext.Current.CancellationToken); await store.ExecuteAsync( ct => store.RevokeAsync( token.TokenHash, Now.AddMinutes(20), "replacement-2", - ct)); + ct), ct: TestContext.Current.CancellationToken); var persisted = await store.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -250,12 +250,12 @@ public async Task TryConsumeAsync_WhenTokenIsActive_ConsumesTokenAndReturnsTrue( token.TokenHash, consumedAt, "replacement-token", - ct)); + ct), ct: TestContext.Current.CancellationToken); consumed.Should().BeTrue(); var persisted = - await store.FindByHashAsync(token.TokenHash); + await store.FindByHashAsync(token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -284,7 +284,7 @@ public async Task TryConsumeAsync_WhenTokenAlreadyConsumed_ReturnsFalseAndDoesNo token.TokenHash, firstConsumedAt, "replacement-1", - ct)); + ct), ct: TestContext.Current.CancellationToken); first.Should().BeTrue(); @@ -293,12 +293,12 @@ public async Task TryConsumeAsync_WhenTokenAlreadyConsumed_ReturnsFalseAndDoesNo token.TokenHash, Now.AddMinutes(20), "replacement-2", - ct)); + ct), ct: TestContext.Current.CancellationToken); second.Should().BeFalse(); var persisted = - await store.FindByHashAsync(token.TokenHash); + await store.FindByHashAsync(token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -320,7 +320,7 @@ public async Task TryConsumeAsync_WhenTokenDoesNotExist_ReturnsFalse() "missing-token", Now, "replacement-token", - ct)); + ct), ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); } @@ -349,15 +349,15 @@ public async Task TryConsumeAsync_IsTenantIsolated() tokenA.TokenHash, Now.AddMinutes(10), "replacement-a", - ct)); + ct), ct: TestContext.Current.CancellationToken); consumed.Should().BeTrue(); var persistedA = - await storeA.FindByHashAsync(tokenA.TokenHash); + await storeA.FindByHashAsync(tokenA.TokenHash, ct: TestContext.Current.CancellationToken); var persistedB = - await storeB.FindByHashAsync(tokenB.TokenHash); + await storeB.FindByHashAsync(tokenB.TokenHash, ct: TestContext.Current.CancellationToken); persistedA!.IsRevoked.Should().BeTrue(); @@ -387,14 +387,14 @@ public async Task TryConsumeAsync_WhenCalledConcurrently_AllowsExactlyOneConsume token.TokenHash, consumedAt, "replacement-a", - ct)); + ct), ct: TestContext.Current.CancellationToken); var taskB = storeB.ExecuteAsync( ct => storeB.TryConsumeAsync( token.TokenHash, consumedAt, "replacement-b", - ct)); + ct), ct: TestContext.Current.CancellationToken); var results = await Task.WhenAll( taskA, @@ -408,7 +408,7 @@ public async Task TryConsumeAsync_WhenCalledConcurrently_AllowsExactlyOneConsume var persisted = await storeA.FindByHashAsync( - token.TokenHash); + token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); @@ -438,7 +438,7 @@ public async Task TryConsumeAsync_WhenAlreadyConsumed_DoesNotOverwriteWinningRep token.TokenHash, winningTime, "winner-replacement", - ct)); + ct), ct: TestContext.Current.CancellationToken); winner.Should().BeTrue(); @@ -447,12 +447,12 @@ public async Task TryConsumeAsync_WhenAlreadyConsumed_DoesNotOverwriteWinningRep token.TokenHash, Now.AddMinutes(20), "loser-replacement", - ct)); + ct), ct: TestContext.Current.CancellationToken); loser.Should().BeFalse(); var persisted = - await store.FindByHashAsync(token.TokenHash); + await store.FindByHashAsync(token.TokenHash, ct: TestContext.Current.CancellationToken); persisted!.RevokedAt.Should() .Be(winningTime); @@ -495,12 +495,12 @@ await store.ExecuteAsync( ct => store.RevokeBySessionAsync( session, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(token1.TokenHash))! + (await store.FindByHashAsync(token1.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(token2.TokenHash))! + (await store.FindByHashAsync(token2.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); } @@ -539,12 +539,12 @@ await store.ExecuteAsync( ct => store.RevokeBySessionAsync( targetSession, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(target.TokenHash))! + (await store.FindByHashAsync(target.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(other.TokenHash))! + (await store.FindByHashAsync(other.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -581,12 +581,12 @@ await store.ExecuteAsync( ct => store.RevokeByChainAsync( chain, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(token1.TokenHash))! + (await store.FindByHashAsync(token1.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(token2.TokenHash))! + (await store.FindByHashAsync(token2.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); } @@ -621,12 +621,12 @@ await store.ExecuteAsync( ct => store.RevokeByChainAsync( targetChain, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(target.TokenHash))! + (await store.FindByHashAsync(target.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(other.TokenHash))! + (await store.FindByHashAsync(other.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -662,12 +662,12 @@ await store.ExecuteAsync( ct => store.RevokeAllForUserAsync( user, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(token1.TokenHash))! + (await store.FindByHashAsync(token1.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(token2.TokenHash))! + (await store.FindByHashAsync(token2.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); } @@ -696,12 +696,12 @@ await store.ExecuteAsync( ct => store.RevokeAllForUserAsync( targetUser, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await store.FindByHashAsync(target.TokenHash))! + (await store.FindByHashAsync(target.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await store.FindByHashAsync(other.TokenHash))! + (await store.FindByHashAsync(other.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -737,13 +737,13 @@ await storeA.ExecuteAsync( ct => storeA.RevokeAsync( tokenA.TokenHash, Now.AddMinutes(10), - ct: ct)); + ct: ct), ct: TestContext.Current.CancellationToken); var persistedA = - await storeA.FindByHashAsync(tokenA.TokenHash); + await storeA.FindByHashAsync(tokenA.TokenHash, ct: TestContext.Current.CancellationToken); var persistedB = - await storeB.FindByHashAsync(tokenB.TokenHash); + await storeB.FindByHashAsync(tokenB.TokenHash, ct: TestContext.Current.CancellationToken); persistedA!.IsRevoked.Should().BeTrue(); persistedB!.IsRevoked.Should().BeFalse(); @@ -777,12 +777,12 @@ await storeA.ExecuteAsync( ct => storeA.RevokeBySessionAsync( session, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await storeA.FindByHashAsync(tokenA.TokenHash))! + (await storeA.FindByHashAsync(tokenA.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await storeB.FindByHashAsync(tokenB.TokenHash))! + (await storeB.FindByHashAsync(tokenB.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -813,12 +813,12 @@ await storeA.ExecuteAsync( ct => storeA.RevokeByChainAsync( chain, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await storeA.FindByHashAsync(tokenA.TokenHash))! + (await storeA.FindByHashAsync(tokenA.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await storeB.FindByHashAsync(tokenB.TokenHash))! + (await storeB.FindByHashAsync(tokenB.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -849,12 +849,12 @@ await storeA.ExecuteAsync( ct => storeA.RevokeAllForUserAsync( user, Now.AddMinutes(10), - ct)); + ct), ct: TestContext.Current.CancellationToken); - (await storeA.FindByHashAsync(tokenA.TokenHash))! + (await storeA.FindByHashAsync(tokenA.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeTrue(); - (await storeB.FindByHashAsync(tokenB.TokenHash))! + (await storeB.FindByHashAsync(tokenB.TokenHash, ct: TestContext.Current.CancellationToken))! .IsRevoked.Should().BeFalse(); } @@ -884,16 +884,16 @@ await store.ExecuteAsync( token.TokenHash, firstRevocation, "replacement-token", - ct)); + ct), ct: TestContext.Current.CancellationToken); await store.ExecuteAsync( ct => store.RevokeAllForUserAsync( user, Now.AddMinutes(20), - ct)); + ct), ct: TestContext.Current.CancellationToken); var persisted = - await store.FindByHashAsync(token.TokenHash); + await store.FindByHashAsync(token.TokenHash, ct: TestContext.Current.CancellationToken); persisted!.RevokedAt.Should().Be(firstRevocation); @@ -962,7 +962,7 @@ public async Task StoreAsync_WhenChainIdIsNull_PersistsToken() await StoreAsync(store, token); var persisted = - await store.FindByHashAsync(token.TokenHash); + await store.FindByHashAsync(token.TokenHash, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.ChainId.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs index 453fe9cc..730fa53c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -51,7 +51,7 @@ public async Task IssueAccessTokenAsync_WhenModeIsPureOpaque_ShouldIssueOpaqueTo var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); result.Format.Should().Be(TokenFormat.Opaque); result.Token.Should().Be(OpaqueToken); @@ -79,7 +79,7 @@ public async Task IssueAccessTokenAsync_WhenModeIsHybrid_CurrentlyIssuesOpaqueTo var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); // Characterizes current Hybrid behavior. // Production code explicitly marks JWT Hybrid as a future decision. @@ -104,7 +104,7 @@ public async Task IssueAccessTokenAsync_WhenOpaque_ShouldUseConfiguredLifetime() var result = await fixture.Sut.IssueAccessTokenAsync( flow, - CreateTokenContext()); + CreateTokenContext(), ct: TestContext.Current.CancellationToken); result.ExpiresAt.Should() .Be(Now.AddMinutes(17)); @@ -128,7 +128,7 @@ public async Task IssueAccessTokenAsync_WhenModeUsesJwt_ShouldIssueJwt( var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); result.Format.Should().Be(TokenFormat.Jwt); result.Token.Should().Be(Jwt); @@ -169,7 +169,7 @@ public async Task IssueAccessTokenAsync_WhenJwt_ShouldBuildDescriptorFromContext var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -227,7 +227,7 @@ public async Task IssueAccessTokenAsync_WhenJwt_ShouldIncludeSubjectAndTenantCla await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -270,7 +270,7 @@ public async Task IssueAccessTokenAsync_WhenJwtAndSessionExists_ShouldIncludeSes var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); captured!.Claims.Should() .ContainKey("sid"); @@ -304,7 +304,7 @@ public async Task IssueAccessTokenAsync_WhenJwtAndSessionDoesNotExist_ShouldNotI var result = await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); captured!.Claims.Should() .NotContainKey("sid"); @@ -332,7 +332,7 @@ public async Task IssueAccessTokenAsync_WhenJwtIdEnabled_ShouldGenerateJti() await fixture.Sut.IssueAccessTokenAsync( flow, - CreateTokenContext()); + CreateTokenContext(), ct: TestContext.Current.CancellationToken); fixture.OpaqueGenerator.Verify( x => x.GenerateJwtId(), @@ -365,7 +365,7 @@ public async Task IssueAccessTokenAsync_WhenJwtIdDisabled_ShouldNotGenerateJti() await fixture.Sut.IssueAccessTokenAsync( flow, - CreateTokenContext()); + CreateTokenContext(), ct: TestContext.Current.CancellationToken); fixture.OpaqueGenerator.Verify( x => x.GenerateJwtId(), @@ -391,7 +391,7 @@ public async Task IssueRefreshTokenAsync_WhenModeIsPureOpaque_ShouldReturnNull() await fixture.Sut.IssueRefreshTokenAsync( flow, CreateTokenContext(), - RefreshTokenPersistence.Persist); + RefreshTokenPersistence.Persist, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); @@ -426,7 +426,7 @@ public async Task IssueRefreshTokenAsync_WhenSessionIdIsMissing_ShouldReturnNull await fixture.Sut.IssueRefreshTokenAsync( flow, context, - RefreshTokenPersistence.Persist); + RefreshTokenPersistence.Persist, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); @@ -459,7 +459,7 @@ public async Task IssueRefreshTokenAsync_ShouldGenerateAndHashRawToken() var context = CreateTokenContext(sessionId: sessionId); - var result = await fixture.Sut.IssueRefreshTokenAsync(flow, context, RefreshTokenPersistence.DoNotPersist); + var result = await fixture.Sut.IssueRefreshTokenAsync(flow, context, RefreshTokenPersistence.DoNotPersist, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Token.Should().Be(RefreshToken); @@ -484,7 +484,7 @@ public async Task IssueRefreshTokenAsync_ShouldUseConfiguredRefreshLifetime() await fixture.Sut.IssueRefreshTokenAsync( flow, CreateTokenContext(sessionId: sessionId), - RefreshTokenPersistence.DoNotPersist); + RefreshTokenPersistence.DoNotPersist, ct: TestContext.Current.CancellationToken); result!.ExpiresAt.Should() .Be(Now.AddDays(14)); @@ -525,7 +525,7 @@ public async Task IssueRefreshTokenAsync_WhenPersistenceIsPersist_ShouldStoreHas await fixture.Sut.IssueRefreshTokenAsync( flow, context, - RefreshTokenPersistence.Persist); + RefreshTokenPersistence.Persist, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -577,7 +577,7 @@ public async Task IssueRefreshTokenAsync_WhenPersistenceIsDoNotPersist_ShouldNot await fixture.Sut.IssueRefreshTokenAsync( flow, CreateTokenContext(sessionId: sessionId), - RefreshTokenPersistence.DoNotPersist); + RefreshTokenPersistence.DoNotPersist, ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); @@ -606,7 +606,7 @@ public async Task IssueRefreshTokenAsync_WhenPersisting_ShouldExecuteInsideStore await fixture.Sut.IssueRefreshTokenAsync( flow, CreateTokenContext(sessionId: sessionId), - RefreshTokenPersistence.Persist); + RefreshTokenPersistence.Persist, ct: TestContext.Current.CancellationToken); fixture.Store.Verify( x => x.ExecuteAsync( @@ -702,7 +702,7 @@ public async Task IssueAccessTokenAsync_WhenCustomClaimsContainReservedClaims_Sh await fixture.Sut.IssueAccessTokenAsync( flow, - context); + context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -841,7 +841,7 @@ public async Task IssueAccessTokenAsync_WhenMultipleRolesExist_ShouldPreserveAll .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); captured!.Claims.Should().ContainKey("role"); @@ -872,7 +872,7 @@ public async Task IssueAccessTokenAsync_WhenMultiplePermissionsExist_ShouldPrese .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); var permissions = captured!.Claims!["uauth:permission"] .Should().BeAssignableTo().Subject; @@ -898,7 +898,7 @@ public async Task IssueAccessTokenAsync_WhenSingleClaimExists_ShouldUseStringVal .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured!.Claims!["department"] .Should().Be("legal"); @@ -926,7 +926,7 @@ public async Task IssueAccessTokenAsync_WhenClaimsAreEmpty_ShouldOnlyIncludeFram descriptor => captured = descriptor) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -971,7 +971,7 @@ public async Task IssueAccessTokenAsync_WhenCustomClaimIsReserved_ShouldIgnoreIt .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured.Should().NotBeNull(); @@ -1003,7 +1003,7 @@ public async Task IssueAccessTokenAsync_WhenCustomSidExists_ShouldUseFrameworkSe .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured!.Claims!["sid"] .Should().Be(sessionId.Value); @@ -1029,7 +1029,7 @@ public async Task IssueAccessTokenAsync_WhenCustomJtiExists_ShouldUseGeneratedJt .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured!.Claims!["jti"] .Should().Be(JwtId); @@ -1059,7 +1059,7 @@ public async Task IssueAccessTokenAsync_WhenJwtIdDisabled_ShouldRejectCustomJti( .Callback(d => captured = d) .Returns(Jwt); - await fixture.Sut.IssueAccessTokenAsync(flow, context); + await fixture.Sut.IssueAccessTokenAsync(flow, context, ct: TestContext.Current.CancellationToken); captured!.Claims.Should().NotContainKey("jti"); @@ -1091,10 +1091,10 @@ public async Task IssueTokens_ShouldUseEffectiveLifetimes() "effective-session-0000000000000000000001")); var access = await fixture.Sut.IssueAccessTokenAsync( - flow, context); + flow, context, ct: TestContext.Current.CancellationToken); var refresh = await fixture.Sut.IssueRefreshTokenAsync( - flow, context, RefreshTokenPersistence.DoNotPersist); + flow, context, RefreshTokenPersistence.DoNotPersist, ct: TestContext.Current.CancellationToken); access.ExpiresAt.Should().Be(Now.AddMinutes(5)); refresh.Should().NotBeNull(); @@ -1115,7 +1115,7 @@ public async Task IssueRefreshTokenAsync_WhenIssueRefreshDisabled_ShouldReturnNu var result = await fixture.Sut.IssueRefreshTokenAsync( flow, CreateTokenContext(sessionId: null), - RefreshTokenPersistence.Persist); + RefreshTokenPersistence.Persist, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 557935bc..497d72a1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -20,15 +20,15 @@ public async Task Save_should_increment_version() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); - var copy = await store.GetByIdAsync(id); + var copy = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); var expected = copy!.Version; copy.ChangeValue("b@test.com", "b@test.com", now); - await store.SaveAsync(copy, expected); + await store.SaveAsync(copy, expected, ct: TestContext.Current.CancellationToken); - var updated = await store.GetByIdAsync(id); + var updated = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); Assert.Equal(expected + 1, updated!.Version); } @@ -41,18 +41,18 @@ public async Task Delete_should_throw_when_version_conflicts() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); - var copy1 = await store.GetByIdAsync(id); - var copy2 = await store.GetByIdAsync(id); + var copy1 = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); + var copy2 = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); var expected1 = copy1!.Version; copy1.MarkDeleted(now); - await store.SaveAsync(copy1, expected1); + await store.SaveAsync(copy1, expected1, ct: TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => { - await store.DeleteAsync(copy2!.Id, copy2!.Version, DeleteMode.Soft, now); + await store.DeleteAsync(copy2!.Id, copy2!.Version, DeleteMode.Soft, now, ct: TestContext.Current.CancellationToken); }); } @@ -64,7 +64,7 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); int success = 0; int conflicts = 0; @@ -97,7 +97,7 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() Assert.Equal(1, success); Assert.Equal(1, conflicts); - var final = await store.GetByIdAsync(id); + var final = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); Assert.True(final!.IsPrimary); Assert.Equal(1, final.Version); } @@ -111,21 +111,21 @@ public async Task Update_should_throw_concurrency_when_versions_conflict() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); - var copy1 = await store.GetByIdAsync(id); - var copy2 = await store.GetByIdAsync(id); + var copy1 = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); + var copy2 = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); var expected1 = copy1!.Version; copy1.ChangeValue("b@test.com", "b@test.com", now); - await store.SaveAsync(copy1, expected1); + await store.SaveAsync(copy1, expected1, ct: TestContext.Current.CancellationToken); var expected2 = copy2!.Version; copy2.ChangeValue("c@test.com", "c@test.com", now); await Assert.ThrowsAsync(async () => { - await store.SaveAsync(copy2, expected2); + await store.SaveAsync(copy2, expected2, ct: TestContext.Current.CancellationToken); }); } @@ -138,7 +138,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); int success = 0; int conflicts = 0; @@ -174,7 +174,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic Assert.Equal(1, success); Assert.Equal(1, conflicts); - var final = await store.GetByIdAsync(id); + var final = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); Assert.NotNull(final); Assert.Equal(1, final!.Version); @@ -189,7 +189,7 @@ public async Task High_contention_updates_should_allow_only_one_success() 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); int success = 0; int conflicts = 0; @@ -222,7 +222,7 @@ public async Task High_contention_updates_should_allow_only_one_success() Assert.True(success >= 1); Assert.Equal(20, success + conflicts); - var final = await store.GetByIdAsync(id); + var final = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); Assert.Equal(success, final!.Version); } @@ -236,7 +236,7 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic 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); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); int success = 0; int conflicts = 0; @@ -269,7 +269,7 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic Assert.Equal(1, success); Assert.Equal(19, conflicts); - var final = await store.GetByIdAsync(id); + var final = await store.GetByIdAsync(id, ct: TestContext.Current.CancellationToken); Assert.True(final!.IsPrimary); Assert.Equal(1, final.Version); @@ -289,8 +289,8 @@ public async Task Two_identifiers_racing_for_primary_should_allow() 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); + await store.AddAsync(identifier1, ct: TestContext.Current.CancellationToken); + await store.AddAsync(identifier2, ct: TestContext.Current.CancellationToken); int success = 0; int conflicts = 0; @@ -318,7 +318,7 @@ public async Task Two_identifiers_racing_for_primary_should_allow() { Interlocked.Increment(ref conflicts); } - }), + }, cancellationToken: TestContext.Current.CancellationToken), Task.Run(async () => { try @@ -338,7 +338,7 @@ public async Task Two_identifiers_racing_for_primary_should_allow() { Interlocked.Increment(ref conflicts); } - }) + }, cancellationToken: TestContext.Current.CancellationToken) }; await Task.WhenAll(tasks); @@ -346,7 +346,7 @@ public async Task Two_identifiers_racing_for_primary_should_allow() Assert.Equal(2, success); Assert.Equal(0, conflicts); - var all = await store.GetByUserAsync(user); + var all = await store.GetByUserAsync(user, ct: TestContext.Current.CancellationToken); var primaries = all .Where(x => x.Type == UserIdentifierType.Email && x.IsPrimary) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs index 860497da..1b8df684 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs @@ -28,7 +28,7 @@ public async Task QueryAsync_WhenEmpty_ReturnsEmptyPage() var store = db.CreateStore(TenantA); - var result = await store.QueryAsync(new UserQuery()); + var result = await store.QueryAsync(new UserQuery(), ct: TestContext.Current.CancellationToken); result.Items.Should().BeEmpty(); result.TotalCount.Should().Be(0); @@ -53,10 +53,10 @@ await db.SeedAsync(TenantB, ]); var resultA = await db.CreateStore(TenantA) - .QueryAsync(new UserQuery()); + .QueryAsync(new UserQuery(), ct: TestContext.Current.CancellationToken); var resultB = await db.CreateStore(TenantB) - .QueryAsync(new UserQuery()); + .QueryAsync(new UserQuery(), ct: TestContext.Current.CancellationToken); resultA.TotalCount.Should().Be(1); resultB.TotalCount.Should().Be(1); @@ -87,7 +87,7 @@ public async Task QueryAsync_With1501Users_ReturnsCorrectLastPage() { PageNumber = 7, PageSize = 250 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1501); result.Items.Should().ContainSingle(); @@ -119,7 +119,7 @@ public async Task QueryAsync_SearchesBeyondFirst1000Users() { Search = "target@example.com", PageSize = 50 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Should().ContainSingle(); @@ -151,7 +151,7 @@ public async Task QueryAsync_SearchesSupportedFields( await db.SeedAsync(TenantA, [target]); var result = await db.CreateStore(TenantA) - .QueryAsync(new UserQuery { Search = search }); + .QueryAsync(new UserQuery { Search = search }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Should().ContainSingle(); @@ -173,7 +173,7 @@ await db.SeedAsync(TenantA, .QueryAsync(new UserQuery { Search = "does-not-exist" - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(0); result.Items.Should().BeEmpty(); @@ -195,7 +195,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .QueryAsync(new UserQuery { Search = search }); + .QueryAsync(new UserQuery { Search = search }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(2); } @@ -224,7 +224,7 @@ public async Task QueryAsync_PaginationHasNoMissingOrDuplicateUsers() { PageNumber = page, PageSize = 10 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(105); collected.AddRange(result.Items.Select(x => x.UserKey)); @@ -254,7 +254,7 @@ public async Task QueryAsync_PageBeyondLast_ReturnsEmptyButCorrectTotal() { PageNumber = 10, PageSize = 10 - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(15); result.Items.Should().BeEmpty(); @@ -276,7 +276,7 @@ await db.SeedAsync(TenantA, { PageNumber = int.MaxValue, PageSize = 1000 - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().BeEmpty(); result.TotalCount.Should().Be(1); @@ -304,7 +304,7 @@ public async Task QueryAsync_FiltersByStatus() .QueryAsync(new UserQuery { Status = UserStatus.Active - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Single().UserKey.Should().Be(active.UserKey); @@ -328,9 +328,9 @@ public async Task QueryAsync_ExcludesDeletedUsersByDefault() var store = db.CreateStore(TenantA); - var normal = await store.QueryAsync(new UserQuery()); + var normal = await store.QueryAsync(new UserQuery(), ct: TestContext.Current.CancellationToken); var includingDeleted = await store.QueryAsync( - new UserQuery { IncludeDeleted = true }); + new UserQuery { IncludeDeleted = true }, ct: TestContext.Current.CancellationToken); normal.TotalCount.Should().Be(1); normal.Items.Single().UserKey.Should().Be(active.UserKey); @@ -352,7 +352,7 @@ public async Task QueryAsync_UserWithoutProfileIsStillReturned() await db.SeedAsync(TenantA, [user]); var result = await db.CreateStore(TenantA) - .QueryAsync(new UserQuery()); + .QueryAsync(new UserQuery(), ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items[0].UserKey.Should().Be(user.UserKey); @@ -379,13 +379,13 @@ await db.SeedAsync(TenantA, var store = db.CreateStore(TenantA); var defaultResult = await store.QueryAsync( - new UserQuery()); + new UserQuery(), ct: TestContext.Current.CancellationToken); var businessResult = await store.QueryAsync( new UserQuery { ProfileKey = customProfile - }); + }, ct: TestContext.Current.CancellationToken); defaultResult.TotalCount.Should().Be(1); defaultResult.Items.Single().DisplayName.Should().BeNull(); @@ -415,7 +415,7 @@ public async Task QueryAsync_SearchTreatsSqlWildcardsLiterally( .QueryAsync(new UserQuery { Search = search - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Single().UserKey.Should().Be(target.UserKey); @@ -457,7 +457,7 @@ await db.SeedAsync(TenantA, .QueryAsync(new UserQuery { Search = key.Value - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(1); result.Items.Single().UserKey.Should().Be(key); @@ -479,7 +479,7 @@ await db.SeedAsync(TenantA, .QueryAsync(new UserQuery { Search = "user-key-12345" - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(0); result.Items.Should().BeEmpty(); @@ -491,7 +491,7 @@ public async Task GetByKeysAsync_WhenEmpty_ReturnsEmpty() await using var db = await CreateDatabaseAsync(); var result = await db.CreateStore(TenantA) - .GetByKeysAsync(Array.Empty()); + .GetByKeysAsync(Array.Empty(), ct: TestContext.Current.CancellationToken); result.Should().BeEmpty(); } @@ -511,7 +511,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([first, first, second, second]); + .GetByKeysAsync([first, first, second, second], ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); @@ -533,7 +533,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([existing, missing]); + .GetByKeysAsync([existing, missing], ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); @@ -559,7 +559,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([active, deleted]); + .GetByKeysAsync([active, deleted], ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); @@ -595,10 +595,10 @@ await db.SeedAsync(TenantB, }; var resultA = await db.CreateStore(TenantA) - .GetByKeysAsync(requested); + .GetByKeysAsync(requested, ct: TestContext.Current.CancellationToken); var resultB = await db.CreateStore(TenantB) - .GetByKeysAsync(requested); + .GetByKeysAsync(requested, ct: TestContext.Current.CancellationToken); resultA.Should().HaveCount(2); resultB.Should().HaveCount(2); @@ -638,7 +638,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([key]); + .GetByKeysAsync([key], ct: TestContext.Current.CancellationToken); var user = result.Should().ContainSingle().Subject; @@ -667,7 +667,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([key]); + .GetByKeysAsync([key], ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); @@ -693,7 +693,7 @@ await db.SeedAsync(TenantA, ]); var result = await db.CreateStore(TenantA) - .GetByKeysAsync([key]); + .GetByKeysAsync([key], ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result[0].DisplayName.Should().BeNull(); @@ -718,7 +718,7 @@ public async Task GetByKeysAsync_With250Keys_ReturnsAllUsers() .ToArray(); var result = await db.CreateStore(TenantA) - .GetByKeysAsync(keys); + .GetByKeysAsync(keys, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(250); @@ -748,7 +748,7 @@ public async Task GetByKeysAsync_FindsUsersBeyondFirstLifecyclePage() }; var result = await db.CreateStore(TenantA) - .GetByKeysAsync(requested); + .GetByKeysAsync(requested, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(3); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs index 38d11f3a..fa8a4bfc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs @@ -55,9 +55,9 @@ public async Task AddAsync_PersistsIdentifier() TenantA, isPrimary: true); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); - var persisted = await store.GetByIdAsync(identifier.Id); + var persisted = await store.GetByIdAsync(identifier.Id, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Id.Should().Be(identifier.Id); @@ -77,7 +77,7 @@ public async Task GetByIdAsync_WhenMissing_ReturnsNull() await using var db = await CreateDatabaseAsync(); var store = db.CreateStore(TenantA); - var result = await store.GetByIdAsync(Guid.NewGuid()); + var result = await store.GetByIdAsync(Guid.NewGuid(), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -90,11 +90,11 @@ public async Task GetAsync_ByTypeAndNormalizedValue_ReturnsIdentifier() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( UserIdentifierType.Email, - "USER@EXAMPLE.COM"); + "USER@EXAMPLE.COM", ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(identifier.Id); @@ -108,17 +108,17 @@ public async Task GetAsync_ByTypeAndNormalizedValue_ExcludesDeleted() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( identifier.Id, expectedVersion: 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.GetAsync( identifier.Type, - identifier.NormalizedValue); + identifier.NormalizedValue, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -151,17 +151,17 @@ public async Task GetByUserAsync_ReturnsOnlyActiveIdentifiersForUser() value: "b@example.com", normalizedValue: "B@EXAMPLE.COM"); - await store.AddAsync(first); - await store.AddAsync(second); - await store.AddAsync(otherUser); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); + await store.AddAsync(otherUser, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( second.Id, 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); - var result = await store.GetByUserAsync(userA); + var result = await store.GetByUserAsync(userA, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().Id.Should().Be(first.Id); @@ -179,7 +179,7 @@ public async Task SaveAsync_PersistsChangesAndIncrementsVersion() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var changedAt = Now.AddMinutes(10); @@ -190,9 +190,9 @@ public async Task SaveAsync_PersistsChangesAndIncrementsVersion() await store.SaveAsync( identifier, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); - var persisted = await store.GetByIdAsync(identifier.Id); + var persisted = await store.GetByIdAsync(identifier.Id, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Value.Should().Be("changed@example.com"); @@ -223,10 +223,10 @@ public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); identifier.MarkVerified(Now.AddMinutes(1)); - await store.SaveAsync(identifier, 0); + await store.SaveAsync(identifier, 0, ct: TestContext.Current.CancellationToken); var act = () => store.SaveAsync( identifier, @@ -262,11 +262,11 @@ public async Task AddAsync_PrimaryIdentifier_UnsetsExistingPrimaryOfSameType() normalizedValue: "SECOND@EXAMPLE.COM", isPrimary: true); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); - var persistedFirst = await store.GetByIdAsync(first.Id); - var persistedSecond = await store.GetByIdAsync(second.Id); + var persistedFirst = await store.GetByIdAsync(first.Id, ct: TestContext.Current.CancellationToken); + var persistedSecond = await store.GetByIdAsync(second.Id, ct: TestContext.Current.CancellationToken); persistedFirst!.IsPrimary.Should().BeFalse(); persistedSecond!.IsPrimary.Should().BeTrue(); @@ -296,13 +296,13 @@ public async Task AddAsync_PrimaryIdentifier_DoesNotUnsetPrimaryOfDifferentType( "ALICE", isPrimary: true); - await store.AddAsync(email); - await store.AddAsync(username); + await store.AddAsync(email, ct: TestContext.Current.CancellationToken); + await store.AddAsync(username, ct: TestContext.Current.CancellationToken); - (await store.GetByIdAsync(email.Id))! + (await store.GetByIdAsync(email.Id, ct: TestContext.Current.CancellationToken))! .IsPrimary.Should().BeTrue(); - (await store.GetByIdAsync(username.Id))! + (await store.GetByIdAsync(username.Id, ct: TestContext.Current.CancellationToken))! .IsPrimary.Should().BeTrue(); } @@ -327,17 +327,17 @@ public async Task SaveAsync_SetPrimary_UnsetsExistingPrimaryOfSameType() value: "second@example.com", normalizedValue: "SECOND@EXAMPLE.COM"); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); second.SetPrimary(Now.AddMinutes(1)); - await store.SaveAsync(second, 0); + await store.SaveAsync(second, 0, ct: TestContext.Current.CancellationToken); - (await store.GetByIdAsync(first.Id))! + (await store.GetByIdAsync(first.Id, ct: TestContext.Current.CancellationToken))! .IsPrimary.Should().BeFalse(); - (await store.GetByIdAsync(second.Id))! + (await store.GetByIdAsync(second.Id, ct: TestContext.Current.CancellationToken))! .IsPrimary.Should().BeTrue(); } @@ -355,7 +355,7 @@ public async Task DeleteAsync_Soft_PreservesRecordAndClearsPrimary() TenantA, isPrimary: true); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var deletedAt = Now.AddHours(1); @@ -363,9 +363,9 @@ await store.DeleteAsync( identifier.Id, 0, DeleteMode.Soft, - deletedAt); + deletedAt, ct: TestContext.Current.CancellationToken); - var persisted = await store.GetByIdAsync(identifier.Id); + var persisted = await store.GetByIdAsync(identifier.Id, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsDeleted.Should().BeTrue(); @@ -382,15 +382,15 @@ public async Task DeleteAsync_Hard_RemovesIdentifier() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( identifier.Id, 0, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); - (await store.GetByIdAsync(identifier.Id)) + (await store.GetByIdAsync(identifier.Id, ct: TestContext.Current.CancellationToken)) .Should().BeNull(); } @@ -402,10 +402,10 @@ public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); identifier.MarkVerified(Now.AddMinutes(1)); - await store.SaveAsync(identifier, 0); + await store.SaveAsync(identifier, 0, ct: TestContext.Current.CancellationToken); var act = () => store.DeleteAsync( identifier.Id, @@ -444,7 +444,7 @@ public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() var storeB = db.CreateStore(TenantB); var identifier = CreateIdentifier(TenantB); - await storeB.AddAsync(identifier); + await storeB.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var act = () => storeA.SaveAsync( identifier, @@ -463,9 +463,9 @@ public async Task GetByIdAsync_IsTenantIsolated() var storeB = db.CreateStore(TenantB); var identifier = CreateIdentifier(TenantB); - await storeB.AddAsync(identifier); + await storeB.AddAsync(identifier, ct: TestContext.Current.CancellationToken); - var result = await storeA.GetByIdAsync(identifier.Id); + var result = await storeA.GetByIdAsync(identifier.Id, ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -535,13 +535,13 @@ public async Task ExistsAsync_TenantAny_FindsMatchingIdentifier() var store = db.CreateStore(TenantA); var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( new IdentifierExistenceQuery( identifier.Type, identifier.NormalizedValue, - IdentifierExistenceScope.TenantAny)); + IdentifierExistenceScope.TenantAny), ct: TestContext.Current.CancellationToken); result.Exists.Should().BeTrue(); result.OwnerUserKey.Should().Be(identifier.UserKey); @@ -555,14 +555,14 @@ public async Task ExistsAsync_WithinUser_DoesNotMatchOtherUser() var store = db.CreateStore(TenantA); var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( new IdentifierExistenceQuery( identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.WithinUser, - UserKey: UserKey.New())); + UserKey: UserKey.New()), ct: TestContext.Current.CancellationToken); result.Exists.Should().BeFalse(); } @@ -577,13 +577,13 @@ public async Task ExistsAsync_TenantPrimaryOnly_IgnoresNonPrimary() TenantA, isPrimary: false); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( new IdentifierExistenceQuery( identifier.Type, identifier.NormalizedValue, - IdentifierExistenceScope.TenantPrimaryOnly)); + IdentifierExistenceScope.TenantPrimaryOnly), ct: TestContext.Current.CancellationToken); result.Exists.Should().BeFalse(); } @@ -595,14 +595,14 @@ public async Task ExistsAsync_ExcludeIdentifierId_ExcludesCurrentIdentifier() var store = db.CreateStore(TenantA); var identifier = CreateIdentifier(TenantA); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( new IdentifierExistenceQuery( identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantAny, - ExcludeIdentifierId: identifier.Id)); + ExcludeIdentifierId: identifier.Id), ct: TestContext.Current.CancellationToken); result.Exists.Should().BeFalse(); } @@ -623,26 +623,26 @@ public async Task ExistsAsync_SoftDeletedIdentifier_RemainsReserved(UserIdentifi normalizedValue: normalizedValue, isPrimary: true); - await store.AddAsync(identifier); + await store.AddAsync(identifier, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( identifier.Id, expectedVersion: 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); // A deleted identifier still reserves its normalized value. var exists = await store.ExistsAsync( new IdentifierExistenceQuery( type, normalizedValue, - IdentifierExistenceScope.TenantAny)); + IdentifierExistenceScope.TenantAny), ct: TestContext.Current.CancellationToken); exists.Exists.Should().BeTrue(); exists.OwnerIdentifierId.Should().Be(identifier.Id); // Ordinary lookup must not resolve deleted identifiers. - var lookup = await store.GetAsync(type, normalizedValue); + var lookup = await store.GetAsync(type, normalizedValue, ct: TestContext.Current.CancellationToken); lookup.Should().BeNull(); // Excluding the matching identifier must still work. @@ -651,7 +651,7 @@ await store.DeleteAsync( type, normalizedValue, IdentifierExistenceScope.TenantAny, - ExcludeIdentifierId: identifier.Id)); + ExcludeIdentifierId: identifier.Id), ct: TestContext.Current.CancellationToken); excluded.Exists.Should().BeFalse(); } @@ -681,12 +681,12 @@ public async Task GetByUsersAsync_ReturnsActiveIdentifiersForRequestedUsersOnly( value: "c@example.com", normalizedValue: "C@EXAMPLE.COM"); - await store.AddAsync(a); - await store.AddAsync(b); - await store.AddAsync(c); + await store.AddAsync(a, ct: TestContext.Current.CancellationToken); + await store.AddAsync(b, ct: TestContext.Current.CancellationToken); + await store.AddAsync(c, ct: TestContext.Current.CancellationToken); var result = await store.GetByUsersAsync( - [userA, userB]); + [userA, userB], ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); result.Select(x => x.UserKey) @@ -721,23 +721,23 @@ public async Task DeleteByUserAsync_Soft_DeletesOnlySpecifiedUsersIdentifiers() value: "b@example.com", normalizedValue: "B@EXAMPLE.COM"); - await store.AddAsync(a1); - await store.AddAsync(a2); - await store.AddAsync(b); + await store.AddAsync(a1, ct: TestContext.Current.CancellationToken); + await store.AddAsync(a2, ct: TestContext.Current.CancellationToken); + await store.AddAsync(b, ct: TestContext.Current.CancellationToken); var deletedAt = Now.AddHours(1); await store.DeleteByUserAsync( userA, DeleteMode.Soft, - deletedAt); + deletedAt, ct: TestContext.Current.CancellationToken); var query = await store.QueryAsync( new UserIdentifierQuery { UserKey = userA, IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); query.Items.Should().HaveCount(2); query.Items.Should().OnlyContain(x => @@ -745,7 +745,7 @@ await store.DeleteByUserAsync( x.DeletedAt == deletedAt && !x.IsPrimary); - (await store.GetByUserAsync(userB)) + (await store.GetByUserAsync(userB, ct: TestContext.Current.CancellationToken)) .Should() .ContainSingle(); } @@ -769,16 +769,16 @@ public async Task DeleteByUserAsync_Hard_RemovesOnlySpecifiedUsersIdentifiers() value: "b@example.com", normalizedValue: "B@EXAMPLE.COM"); - await store.AddAsync(a); - await store.AddAsync(b); + await store.AddAsync(a, ct: TestContext.Current.CancellationToken); + await store.AddAsync(b, ct: TestContext.Current.CancellationToken); await store.DeleteByUserAsync( userA, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); - (await store.GetByIdAsync(a.Id)).Should().BeNull(); - (await store.GetByIdAsync(b.Id)).Should().NotBeNull(); + (await store.GetByIdAsync(a.Id, ct: TestContext.Current.CancellationToken)).Should().BeNull(); + (await store.GetByIdAsync(b.Id, ct: TestContext.Current.CancellationToken)).Should().NotBeNull(); } [Fact] @@ -812,20 +812,20 @@ public async Task QueryAsync_ByDefault_ExcludesDeleted() value: "deleted@example.com", normalizedValue: "DELETED@EXAMPLE.COM"); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( deleted.Id, 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserIdentifierQuery { UserKey = user - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().Id.Should().Be(active.Id); @@ -850,21 +850,21 @@ public async Task QueryAsync_IncludeDeleted_ReturnsDeletedIdentifiers() value: "deleted@example.com", normalizedValue: "DELETED@EXAMPLE.COM"); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( deleted.Id, 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserIdentifierQuery { UserKey = user, IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); result.TotalCount.Should().Be(2); @@ -886,7 +886,7 @@ await store.AddAsync( user, value: $"user{i}@example.com", normalizedValue: $"USER{i}@EXAMPLE.COM", - createdAt: Now.AddMinutes(i))); + createdAt: Now.AddMinutes(i)), ct: TestContext.Current.CancellationToken); } var result = await store.QueryAsync( @@ -896,7 +896,7 @@ await store.AddAsync( PageNumber = 2, PageSize = 2, SortBy = nameof(UserIdentifier.CreatedAt) - }); + }, ct: TestContext.Current.CancellationToken); result.TotalCount.Should().Be(5); result.Items.Should().HaveCount(2); @@ -918,24 +918,24 @@ public async Task QueryAsync_SortByNormalizedValue_IsSupported() await store.AddAsync(CreateIdentifier( TenantA, user, value: "c@example.com", - normalizedValue: "C@EXAMPLE.COM")); + normalizedValue: "C@EXAMPLE.COM"), ct: TestContext.Current.CancellationToken); await store.AddAsync(CreateIdentifier( TenantA, user, value: "a@example.com", - normalizedValue: "A@EXAMPLE.COM")); + normalizedValue: "A@EXAMPLE.COM"), ct: TestContext.Current.CancellationToken); await store.AddAsync(CreateIdentifier( TenantA, user, value: "b@example.com", - normalizedValue: "B@EXAMPLE.COM")); + normalizedValue: "B@EXAMPLE.COM"), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserIdentifierQuery { UserKey = user, SortBy = nameof(UserIdentifier.NormalizedValue) - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.NormalizedValue) .Should() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs index 9b3bb133..ee20b966 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs @@ -44,10 +44,10 @@ public async Task AddAsync_PersistsLifecycle() var lifecycle = CreateLifecycle(TenantA); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new UserLifecycleKey(TenantA, lifecycle.UserKey)); + new UserLifecycleKey(TenantA, lifecycle.UserKey), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Id.Should().Be(lifecycle.Id); @@ -66,7 +66,7 @@ public async Task GetAsync_WhenMissing_ReturnsNull() var store = db.CreateStore(TenantA); var result = await store.GetAsync( - new UserLifecycleKey(TenantA, UserKey.New())); + new UserLifecycleKey(TenantA, UserKey.New()), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -78,10 +78,10 @@ public async Task ExistsAsync_WhenPresent_ReturnsTrue() var store = db.CreateStore(TenantA); var lifecycle = CreateLifecycle(TenantA); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( - new UserLifecycleKey(TenantA, lifecycle.UserKey)); + new UserLifecycleKey(TenantA, lifecycle.UserKey), ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); } @@ -93,7 +93,7 @@ public async Task ExistsAsync_WhenMissing_ReturnsFalse() var store = db.CreateStore(TenantA); var result = await store.ExistsAsync( - new UserLifecycleKey(TenantA, UserKey.New())); + new UserLifecycleKey(TenantA, UserKey.New()), ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); } @@ -106,7 +106,7 @@ public async Task AddAsync_WhenSameUserAlreadyExists_ThrowsConflict() var user = UserKey.New(); - await store.AddAsync(CreateLifecycle(TenantA, user)); + await store.AddAsync(CreateLifecycle(TenantA, user), ct: TestContext.Current.CancellationToken); var act = () => store.AddAsync( CreateLifecycle(TenantA, user)); @@ -141,7 +141,7 @@ public async Task SaveAsync_PersistsDomainChanges_AndIncrementsVersion() var store = db.CreateStore(TenantA); var lifecycle = CreateLifecycle(TenantA); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); var changedAt = Now.AddMinutes(10); @@ -153,10 +153,10 @@ public async Task SaveAsync_PersistsDomainChanges_AndIncrementsVersion() await store.SaveAsync( lifecycle, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( - new UserLifecycleKey(TenantA, lifecycle.UserKey)); + new UserLifecycleKey(TenantA, lifecycle.UserKey), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Status.Should().Be(UserStatus.Suspended); @@ -188,11 +188,11 @@ public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() var store = db.CreateStore(TenantA); var lifecycle = CreateLifecycle(TenantA); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); lifecycle.IncrementSecurityVersion(); - await store.SaveAsync(lifecycle, 0); + await store.SaveAsync(lifecycle, 0, ct: TestContext.Current.CancellationToken); lifecycle.IncrementSecurityVersion(); @@ -217,15 +217,15 @@ public async Task DeleteAsync_Soft_PreservesRecordAndDeletionState() var lifecycle = CreateLifecycle(TenantA); var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( key, expectedVersion: 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); - var persisted = await store.GetAsync(key); + var persisted = await store.GetAsync(key, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsDeleted.Should().BeTrue(); @@ -242,16 +242,16 @@ public async Task DeleteAsync_Hard_RemovesRecord() var lifecycle = CreateLifecycle(TenantA); var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( key, expectedVersion: 0, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); - (await store.GetAsync(key)).Should().BeNull(); - (await store.ExistsAsync(key)).Should().BeFalse(); + (await store.GetAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeNull(); + (await store.ExistsAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeFalse(); } [Fact] @@ -279,10 +279,10 @@ public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() var lifecycle = CreateLifecycle(TenantA); var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); - await store.AddAsync(lifecycle); + await store.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); lifecycle.IncrementSecurityVersion(); - await store.SaveAsync(lifecycle, 0); + await store.SaveAsync(lifecycle, 0, ct: TestContext.Current.CancellationToken); var act = () => store.DeleteAsync( key, @@ -321,7 +321,7 @@ public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() var storeB = db.CreateStore(TenantB); var lifecycle = CreateLifecycle(TenantB); - await storeB.AddAsync(lifecycle); + await storeB.AddAsync(lifecycle, ct: TestContext.Current.CancellationToken); var act = () => storeA.SaveAsync( lifecycle, @@ -339,14 +339,14 @@ public async Task QueryAsync_IsTenantIsolated() var storeA = db.CreateStore(TenantA); var storeB = db.CreateStore(TenantB); - await storeA.AddAsync(CreateLifecycle(TenantA)); - await storeB.AddAsync(CreateLifecycle(TenantB)); + await storeA.AddAsync(CreateLifecycle(TenantA), ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(CreateLifecycle(TenantB), ct: TestContext.Current.CancellationToken); var resultA = await storeA.QueryAsync( - new UserLifecycleQuery()); + new UserLifecycleQuery(), ct: TestContext.Current.CancellationToken); var resultB = await storeB.QueryAsync( - new UserLifecycleQuery()); + new UserLifecycleQuery(), ct: TestContext.Current.CancellationToken); resultA.Items.Should().ContainSingle(); resultA.Items.Single().Tenant.Should().Be(TenantA); @@ -368,17 +368,17 @@ public async Task QueryAsync_ByDefault_ExcludesDeleted() var active = CreateLifecycle(TenantA); var deleted = CreateLifecycle(TenantA); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserLifecycleKey(TenantA, deleted.UserKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( - new UserLifecycleQuery()); + new UserLifecycleQuery(), ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().UserKey.Should().Be(active.UserKey); @@ -394,20 +394,20 @@ public async Task QueryAsync_IncludeDeleted_ReturnsDeletedRecords() var active = CreateLifecycle(TenantA); var deleted = CreateLifecycle(TenantA); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserLifecycleKey(TenantA, deleted.UserKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery { IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); result.TotalCount.Should().Be(2); @@ -433,14 +433,14 @@ public async Task QueryAsync_Status_FiltersResults() Now.AddMinutes(1), UserStatus.Suspended); - await store.AddAsync(active); - await store.AddAsync(suspended); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(suspended, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery { Status = UserStatus.Suspended - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().UserKey.Should().Be(suspended.UserKey); @@ -462,7 +462,7 @@ public async Task QueryAsync_AppliesPagination() await store.AddAsync( CreateLifecycle( TenantA, - createdAt: Now.AddMinutes(i))); + createdAt: Now.AddMinutes(i)), ct: TestContext.Current.CancellationToken); } var result = await store.QueryAsync( @@ -471,7 +471,7 @@ await store.AddAsync( PageNumber = 2, PageSize = 2, SortBy = nameof(UserLifecycle.CreatedAt) - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); result.TotalCount.Should().Be(5); @@ -504,16 +504,16 @@ public async Task QueryAsync_SortsCreatedAtAscending() TenantA, createdAt: Now.AddMinutes(2)); - await store.AddAsync(third); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(third, ct: TestContext.Current.CancellationToken); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery { SortBy = nameof(UserLifecycle.CreatedAt), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.CreatedAt) .Should() @@ -530,20 +530,20 @@ public async Task QueryAsync_SortsCreatedAtDescending() var store = db.CreateStore(TenantA); await store.AddAsync( - CreateLifecycle(TenantA, createdAt: Now.AddMinutes(1))); + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(1)), ct: TestContext.Current.CancellationToken); await store.AddAsync( - CreateLifecycle(TenantA, createdAt: Now.AddMinutes(3))); + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(3)), ct: TestContext.Current.CancellationToken); await store.AddAsync( - CreateLifecycle(TenantA, createdAt: Now.AddMinutes(2))); + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(2)), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery { SortBy = nameof(UserLifecycle.CreatedAt), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.CreatedAt) .Should() @@ -563,16 +563,16 @@ public async Task QueryAsync_SortByUserKey_IsSupported() var secondUser = UserKey.FromString("user-b"); var thirdUser = UserKey.FromString("user-c"); - await store.AddAsync(CreateLifecycle(TenantA, thirdUser)); - await store.AddAsync(CreateLifecycle(TenantA, firstUser)); - await store.AddAsync(CreateLifecycle(TenantA, secondUser)); + await store.AddAsync(CreateLifecycle(TenantA, thirdUser), ct: TestContext.Current.CancellationToken); + await store.AddAsync(CreateLifecycle(TenantA, firstUser), ct: TestContext.Current.CancellationToken); + await store.AddAsync(CreateLifecycle(TenantA, secondUser), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery { SortBy = nameof(UserLifecycle.UserKey), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.UserKey.Value) .Should() @@ -591,20 +591,20 @@ public async Task QueryAsync_SortByDeletedAt_IsSupported() var first = CreateLifecycle(TenantA); var second = CreateLifecycle(TenantA); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserLifecycleKey(TenantA, first.UserKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserLifecycleKey(TenantA, second.UserKey), 0, DeleteMode.Soft, - Now.AddHours(2)); + Now.AddHours(2), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserLifecycleQuery @@ -612,7 +612,7 @@ await store.DeleteAsync( IncludeDeleted = true, SortBy = nameof(UserLifecycle.DeletedAt), Descending = false - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.DeletedAt) .Should() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs index 522d4c29..eed54630 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs @@ -59,14 +59,14 @@ public async Task AddAsync_PersistsProfile() var profile = CreateProfile(TenantA); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); var key = new UserProfileKey( TenantA, profile.UserKey, profile.ProfileKey); - var persisted = await store.GetAsync(key); + var persisted = await store.GetAsync(key, ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.Id.Should().Be(profile.Id); @@ -91,7 +91,7 @@ public async Task GetAsync_WhenMissing_ReturnsNull() new UserProfileKey( TenantA, UserKey.New(), - ProfileKey.Default)); + ProfileKey.Default), ct: TestContext.Current.CancellationToken); result.Should().BeNull(); } @@ -103,13 +103,13 @@ public async Task ExistsAsync_WhenPresent_ReturnsTrue() var store = db.CreateStore(TenantA); var profile = CreateProfile(TenantA); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); var result = await store.ExistsAsync( new UserProfileKey( TenantA, profile.UserKey, - profile.ProfileKey)); + profile.ProfileKey), ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); } @@ -124,7 +124,7 @@ public async Task ExistsAsync_WhenMissing_ReturnsFalse() new UserProfileKey( TenantA, UserKey.New(), - ProfileKey.Default)); + ProfileKey.Default), ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); } @@ -138,7 +138,7 @@ public async Task AddAsync_WhenSameUserAndProfileKeyAlreadyExists_ThrowsConflict var user = UserKey.New(); await store.AddAsync( - CreateProfile(TenantA, user)); + CreateProfile(TenantA, user), ct: TestContext.Current.CancellationToken); var duplicate = CreateProfile( TenantA, @@ -171,10 +171,10 @@ public async Task SameUser_CanHaveDifferentProfileKeys() user, secondaryKey); - await store.AddAsync(defaultProfile); - await store.AddAsync(workProfile); + await store.AddAsync(defaultProfile, ct: TestContext.Current.CancellationToken); + await store.AddAsync(workProfile, ct: TestContext.Current.CancellationToken); - var profiles = await store.GetAllProfilesByUserAsync(user); + var profiles = await store.GetAllProfilesByUserAsync(user, ct: TestContext.Current.CancellationToken); profiles.Should().HaveCount(2); profiles.Select(x => x.ProfileKey) @@ -209,7 +209,7 @@ public async Task SaveAsync_PersistsAllMutableFields_AndIncrementsVersion() var profile = CreateProfile(TenantA); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); var changedAt = Now.AddMinutes(10); @@ -241,13 +241,13 @@ public async Task SaveAsync_PersistsAllMutableFields_AndIncrementsVersion() await store.SaveAsync( profile, - expectedVersion: 0); + expectedVersion: 0, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( new UserProfileKey( TenantA, profile.UserKey, - profile.ProfileKey)); + profile.ProfileKey), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); @@ -294,7 +294,7 @@ public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() var store = db.CreateStore(TenantA); var profile = CreateProfile(TenantA); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); profile.UpdateName( "First", @@ -302,7 +302,7 @@ public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() "First Update", Now.AddMinutes(1)); - await store.SaveAsync(profile, 0); + await store.SaveAsync(profile, 0, ct: TestContext.Current.CancellationToken); profile.UpdateName( "Second", @@ -329,7 +329,7 @@ public async Task DeleteAsync_Soft_PreservesProfileAndDeletionState() var store = db.CreateStore(TenantA); var profile = CreateProfile(TenantA); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); var deletedAt = Now.AddHours(1); @@ -340,13 +340,13 @@ await store.DeleteAsync( profile.ProfileKey), expectedVersion: 0, DeleteMode.Soft, - deletedAt); + deletedAt, ct: TestContext.Current.CancellationToken); var persisted = await store.GetAsync( new UserProfileKey( TenantA, profile.UserKey, - profile.ProfileKey)); + profile.ProfileKey), ct: TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); persisted!.IsDeleted.Should().BeTrue(); @@ -366,16 +366,16 @@ public async Task DeleteAsync_Hard_RemovesProfile() profile.UserKey, profile.ProfileKey); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( key, 0, DeleteMode.Hard, - Now); + Now, ct: TestContext.Current.CancellationToken); - (await store.GetAsync(key)).Should().BeNull(); - (await store.ExistsAsync(key)).Should().BeFalse(); + (await store.GetAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeNull(); + (await store.ExistsAsync(key, ct: TestContext.Current.CancellationToken)).Should().BeFalse(); } [Fact] @@ -409,7 +409,7 @@ public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() profile.UserKey, profile.ProfileKey); - await store.AddAsync(profile); + await store.AddAsync(profile, ct: TestContext.Current.CancellationToken); profile.UpdateName( "Changed", @@ -417,7 +417,7 @@ public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() null, Now.AddMinutes(1)); - await store.SaveAsync(profile, 0); + await store.SaveAsync(profile, 0, ct: TestContext.Current.CancellationToken); var act = () => store.DeleteAsync( key, @@ -456,7 +456,7 @@ public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() var storeB = db.CreateStore(TenantB); var profile = CreateProfile(TenantB); - await storeB.AddAsync(profile); + await storeB.AddAsync(profile, ct: TestContext.Current.CancellationToken); var act = () => storeA.SaveAsync( profile, @@ -474,14 +474,14 @@ public async Task QueryAsync_IsTenantIsolated() var storeA = db.CreateStore(TenantA); var storeB = db.CreateStore(TenantB); - await storeA.AddAsync(CreateProfile(TenantA)); - await storeB.AddAsync(CreateProfile(TenantB)); + await storeA.AddAsync(CreateProfile(TenantA), ct: TestContext.Current.CancellationToken); + await storeB.AddAsync(CreateProfile(TenantB), ct: TestContext.Current.CancellationToken); var resultA = await storeA.QueryAsync( - new UserProfileQuery()); + new UserProfileQuery(), ct: TestContext.Current.CancellationToken); var resultB = await storeB.QueryAsync( - new UserProfileQuery()); + new UserProfileQuery(), ct: TestContext.Current.CancellationToken); resultA.Items.Should().ContainSingle(); resultA.Items.Single().Tenant.Should().Be(TenantA); @@ -514,8 +514,8 @@ public async Task GetAllProfilesByUserAsync_ReturnsActiveProfilesOnly() user, secondaryKey); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey( @@ -524,10 +524,10 @@ await store.DeleteAsync( secondaryKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = - await store.GetAllProfilesByUserAsync(user); + await store.GetAllProfilesByUserAsync(user, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().ProfileKey.Should().Be(ProfileKey.Default); @@ -542,11 +542,11 @@ public async Task GetAllProfilesByUserAsync_DoesNotReturnOtherUsersProfiles() var userA = UserKey.New(); var userB = UserKey.New(); - await store.AddAsync(CreateProfile(TenantA, userA)); - await store.AddAsync(CreateProfile(TenantA, userB)); + await store.AddAsync(CreateProfile(TenantA, userA), ct: TestContext.Current.CancellationToken); + await store.AddAsync(CreateProfile(TenantA, userB), ct: TestContext.Current.CancellationToken); var result = - await store.GetAllProfilesByUserAsync(userA); + await store.GetAllProfilesByUserAsync(userA, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().UserKey.Should().Be(userA); @@ -562,13 +562,13 @@ public async Task GetByUsersAsync_ReturnsRequestedUsersForProfileKey() var userB = UserKey.New(); var userC = UserKey.New(); - await store.AddAsync(CreateProfile(TenantA, userA)); - await store.AddAsync(CreateProfile(TenantA, userB)); - await store.AddAsync(CreateProfile(TenantA, userC)); + await store.AddAsync(CreateProfile(TenantA, userA), ct: TestContext.Current.CancellationToken); + await store.AddAsync(CreateProfile(TenantA, userB), ct: TestContext.Current.CancellationToken); + await store.AddAsync(CreateProfile(TenantA, userC), ct: TestContext.Current.CancellationToken); var result = await store.GetByUsersAsync( [userA, userB], - ProfileKey.Default); + ProfileKey.Default, ct: TestContext.Current.CancellationToken); result.Should().HaveCount(2); @@ -589,8 +589,8 @@ public async Task GetByUsersAsync_ExcludesDeletedProfiles() var first = CreateProfile(TenantA, userA); var second = CreateProfile(TenantA, userB); - await store.AddAsync(first); - await store.AddAsync(second); + await store.AddAsync(first, ct: TestContext.Current.CancellationToken); + await store.AddAsync(second, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey( @@ -599,11 +599,11 @@ await store.DeleteAsync( ProfileKey.Default), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.GetByUsersAsync( [userA, userB], - ProfileKey.Default); + ProfileKey.Default, ct: TestContext.Current.CancellationToken); result.Should().ContainSingle(); result.Single().UserKey.Should().Be(userA); @@ -622,8 +622,8 @@ public async Task QueryAsync_ByDefault_ExcludesDeleted() var active = CreateProfile(TenantA); var deleted = CreateProfile(TenantA); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey( @@ -632,10 +632,10 @@ await store.DeleteAsync( deleted.ProfileKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( - new UserProfileQuery()); + new UserProfileQuery(), ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().Id.Should().Be(active.Id); @@ -651,8 +651,8 @@ public async Task QueryAsync_IncludeDeleted_ReturnsDeletedProfiles() var active = CreateProfile(TenantA); var deleted = CreateProfile(TenantA); - await store.AddAsync(active); - await store.AddAsync(deleted); + await store.AddAsync(active, ct: TestContext.Current.CancellationToken); + await store.AddAsync(deleted, ct: TestContext.Current.CancellationToken); await store.DeleteAsync( new UserProfileKey( @@ -661,13 +661,13 @@ await store.DeleteAsync( deleted.ProfileKey), 0, DeleteMode.Soft, - Now.AddHours(1)); + Now.AddHours(1), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserProfileQuery { IncludeDeleted = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); result.TotalCount.Should().Be(2); @@ -686,19 +686,19 @@ public async Task QueryAsync_ProfileKey_FiltersProfiles() await store.AddAsync( CreateProfile( TenantA, - profileKey: ProfileKey.Default)); + profileKey: ProfileKey.Default), ct: TestContext.Current.CancellationToken); var work = CreateProfile( TenantA, profileKey: workKey); - await store.AddAsync(work); + await store.AddAsync(work, ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserProfileQuery { ProfileKey = workKey - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().ContainSingle(); result.Items.Single().Id.Should().Be(work.Id); @@ -716,7 +716,7 @@ await store.AddAsync( CreateProfile( TenantA, createdAt: Now.AddMinutes(i), - displayName: $"User {i}")); + displayName: $"User {i}"), ct: TestContext.Current.CancellationToken); } var result = await store.QueryAsync( @@ -725,7 +725,7 @@ await store.AddAsync( PageNumber = 2, PageSize = 2, SortBy = nameof(UserProfile.CreatedAt) - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(2); result.TotalCount.Should().Be(5); @@ -754,27 +754,27 @@ await store.AddAsync( TenantA, firstName: "Charlie", lastName: "Zulu", - displayName: "Mike")); + displayName: "Mike"), ct: TestContext.Current.CancellationToken); await store.AddAsync( CreateProfile( TenantA, firstName: "Alice", lastName: "Alpha", - displayName: "Alpha")); + displayName: "Alpha"), ct: TestContext.Current.CancellationToken); await store.AddAsync( CreateProfile( TenantA, firstName: "Bob", lastName: "Mike", - displayName: "Zulu")); + displayName: "Zulu"), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserProfileQuery { SortBy = sortBy - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Should().HaveCount(3); @@ -809,24 +809,24 @@ public async Task QueryAsync_SortsCreatedAtDescending() await store.AddAsync( CreateProfile( TenantA, - createdAt: Now.AddMinutes(1))); + createdAt: Now.AddMinutes(1)), ct: TestContext.Current.CancellationToken); await store.AddAsync( CreateProfile( TenantA, - createdAt: Now.AddMinutes(3))); + createdAt: Now.AddMinutes(3)), ct: TestContext.Current.CancellationToken); await store.AddAsync( CreateProfile( TenantA, - createdAt: Now.AddMinutes(2))); + createdAt: Now.AddMinutes(2)), ct: TestContext.Current.CancellationToken); var result = await store.QueryAsync( new UserProfileQuery { SortBy = nameof(UserProfile.CreatedAt), Descending = true - }); + }, ct: TestContext.Current.CancellationToken); result.Items.Select(x => x.CreatedAt) .Should() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 2087b2c4..7be1eed9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1,25 +1,27 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +ο»Ώusing AwesomeAssertions; +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.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using AwesomeAssertions; using Microsoft.Extensions.Options; using Moq; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Tests.Unit.Users; public sealed class UserApplicationServiceTests { - private static readonly DateTimeOffset Now = - new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Now = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); // ============================================================ // Create @@ -101,7 +103,7 @@ public async Task CreateUserAsync_WhenValid_CreatesLifecycleAndDefaultProfile() var result = await f.Sut.CreateUserAsync( context, - request); + request, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -154,7 +156,7 @@ public async Task CreateUserAsync_CreatesConfiguredIdentifiersForSameUser() var result = await f.Sut.CreateUserAsync( context, - request); + request, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeTrue(); @@ -195,15 +197,17 @@ public async Task CreateUserAsync_InvokesLifecycleIntegrations() context.ResourceTenant, It.IsAny(), request, + clientProfile: UAuthClientProfile.NotSpecified, It.IsAny())) .Returns(Task.CompletedTask); - await f.Sut.CreateUserAsync(context, request); + await f.Sut.CreateUserAsync(context, request, ct: TestContext.Current.CancellationToken); integration.Verify(x => x.OnUserCreatedAsync( context.ResourceTenant, It.IsAny(), request, + clientProfile: UAuthClientProfile.NotSpecified, It.IsAny()), Times.Once); } @@ -282,7 +286,7 @@ public async Task ChangeUserStatusAsync_Self_AllowsActiveToSelfSuspended() await f.Sut.ChangeUserStatusAsync( context, - request); + request, ct: TestContext.Current.CancellationToken); lifecycle.Status.Should().Be(UserStatus.SelfSuspended); @@ -425,7 +429,7 @@ public async Task DeleteMeAsync_SoftDeletesUserData_AndRevokesAllChains() It.IsAny())) .Returns(Task.CompletedTask); - await f.Sut.DeleteMeAsync(context); + await f.Sut.DeleteMeAsync(context, ct: TestContext.Current.CancellationToken); f.LifecycleStore.Verify(x => x.DeleteAsync( It.IsAny(), @@ -492,7 +496,7 @@ public async Task DeleteMeAsync_InvokesIntegrationWithSoftDelete() It.IsAny())) .Returns(Task.CompletedTask); - await f.Sut.DeleteMeAsync(context); + await f.Sut.DeleteMeAsync(context, ct: TestContext.Current.CancellationToken); integration.Verify(x => x.OnUserDeletedAsync( context.ResourceTenant, @@ -570,7 +574,7 @@ public async Task DeleteUserAsync_UsesRequestedDeleteMode( await f.Sut.DeleteUserAsync( context, - request); + request, ct: TestContext.Current.CancellationToken); f.LifecycleStore.Verify(x => x.DeleteAsync( It.IsAny(), @@ -1021,7 +1025,7 @@ await f.Sut.VerifyUserIdentifierAsync( { Id = identifier.Id, Proof = proof - }); + }, ct: TestContext.Current.CancellationToken); identifier.IsVerified.Should().BeTrue(); identifier.VerifiedAt.Should().Be(Now); @@ -1185,7 +1189,7 @@ await f.Sut.DeleteUserIdentifierAsync( { Id = target.Id, Mode = DeleteMode.Soft - }); + }, ct: TestContext.Current.CancellationToken); target.IsDeleted.Should().BeTrue(); target.DeletedAt.Should().Be(Now); @@ -1253,7 +1257,7 @@ await f.Sut.DeleteUserIdentifierAsync( { Id = target.Id, Mode = DeleteMode.Hard - }); + }, ct: TestContext.Current.CancellationToken); f.IdentifierStore.Verify(x => x.DeleteAsync( target.Id, @@ -1352,7 +1356,7 @@ await f.Sut.UpdateUserIdentifierAsync( { Id = identifier.Id, NewValue = "new@example.com" - }); + }, ct: TestContext.Current.CancellationToken); identifier.Value.Should().Be("new@example.com"); identifier.NormalizedValue.Should().Be("new@example.com"); @@ -1401,7 +1405,7 @@ await f.Sut.SetPrimaryUserIdentifierAsync( new SetPrimaryUserIdentifierRequest { Id = identifier.Id - }); + }, ct: TestContext.Current.CancellationToken); identifier.IsPrimary.Should().BeTrue(); identifier.UpdatedAt.Should().Be(Now); @@ -1496,7 +1500,7 @@ await f.Sut.UnsetPrimaryUserIdentifierAsync( new UnsetPrimaryUserIdentifierRequest { Id = target.Id - }); + }, ct: TestContext.Current.CancellationToken); target.IsPrimary.Should().BeFalse(); target.UpdatedAt.Should().Be(Now); @@ -1569,7 +1573,7 @@ await f.Sut.DeleteUserAsync( new DeleteUserRequest { Mode = mode - }); + }, ct: TestContext.Current.CancellationToken); f.SessionStore.Verify(x => x.RevokeAllChainsAsync( target, @@ -1668,7 +1672,7 @@ await f.Sut.DeleteUserAsync( new DeleteUserRequest { Mode = mode - }); + }, ct: TestContext.Current.CancellationToken); f.ProfileStore.Verify(x => x.DeleteAsync( new UserProfileKey( @@ -1715,7 +1719,7 @@ public async Task GetIdentifierAsync_WhenValueInvalid_ReturnsNullWithoutStoreAcc "invalid", "", false, "identifier_invalid")); var result = await f.Sut.GetIdentifierAsync( - context, UserIdentifierType.Email, "invalid"); + context, UserIdentifierType.Email, "invalid", ct: TestContext.Current.CancellationToken); result.Should().BeNull(); @@ -1747,7 +1751,7 @@ public async Task GetIdentifierAsync_WhenNotFound_ReturnsNull() .ReturnsAsync((UserIdentifier?)null); var result = await f.Sut.GetIdentifierAsync( - context, UserIdentifierType.Email, "ALICE@EXAMPLE.COM"); + context, UserIdentifierType.Email, "ALICE@EXAMPLE.COM", ct: TestContext.Current.CancellationToken); result.Should().BeNull(); @@ -1783,7 +1787,7 @@ public async Task GetIdentifierAsync_WhenFound_ReturnsMappedIdentifier() .ReturnsAsync(identifier); var result = await f.Sut.GetIdentifierAsync( - context, UserIdentifierType.Email, "alice@example.com"); + context, UserIdentifierType.Email, "alice@example.com", ct: TestContext.Current.CancellationToken); result.Should().NotBeNull(); result!.Id.Should().Be(identifier.Id); @@ -1804,7 +1808,7 @@ public async Task UserIdentifierExistsAsync_WhenValueInvalid_ReturnsFalseWithout "invalid", "", false, "identifier_invalid")); var result = await f.Sut.UserIdentifierExistsAsync( - context, UserIdentifierType.Email, "invalid"); + context, UserIdentifierType.Email, "invalid", ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); @@ -1844,7 +1848,7 @@ public async Task UserIdentifierExistsAsync_TenantScope_DoesNotPassUserKey( context, UserIdentifierType.Email, "ALICE@EXAMPLE.COM", - scope); + scope, ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); @@ -1880,7 +1884,7 @@ public async Task UserIdentifierExistsAsync_WithinUser_PassesTargetUserKey() context, UserIdentifierType.Username, "Alice", - IdentifierExistenceScope.WithinUser); + IdentifierExistenceScope.WithinUser, ct: TestContext.Current.CancellationToken); result.Should().BeTrue(); } @@ -1908,7 +1912,7 @@ public async Task UserIdentifierExistsAsync_WhenStoreReportsMissing_ReturnsFalse .ReturnsAsync(new UserIdentifierExistenceResult(Exists: false)); var result = await f.Sut.UserIdentifierExistsAsync( - context, UserIdentifierType.Username, "missing"); + context, UserIdentifierType.Username, "missing", ct: TestContext.Current.CancellationToken); result.Should().BeFalse(); } @@ -1944,7 +1948,7 @@ public async Task GetUsersByKeysAsync_WhenEmpty_ReturnsEmptyWithoutStoreAccess() var result = await f.Sut.GetUsersByKeysAsync( context, - Array.Empty()); + Array.Empty(), ct: TestContext.Current.CancellationToken); result.Should().BeEmpty(); @@ -1992,7 +1996,7 @@ public async Task GetUsersByKeysAsync_ShouldNormalizeKeysAndUseResourceTenant() var result = await f.Sut.GetUsersByKeysAsync( context, - [first, first, second]); + [first, first, second], ct: TestContext.Current.CancellationToken); result.Should().BeEquivalentTo(expected); @@ -2029,7 +2033,7 @@ public async Task GetUsersByKeysAsync_When250DistinctKeys_ExecutesSuccessfully() var result = await f.Sut.GetUsersByKeysAsync( context, - keys); + keys, ct: TestContext.Current.CancellationToken); result.Should().BeEmpty(); @@ -2040,11 +2044,329 @@ public async Task GetUsersByKeysAsync_When250DistinctKeys_ExecutesSuccessfully() Times.Once); } + [Fact] + public async Task Adding_identifier_should_fail_when_global_limit_reached() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.Behavior.MaxIdentifiersPerUser = 1; + }); + + var service = runtime.GetUserApplicationService(); + var context = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.AddSelf); + + var act = () => service.AddUserIdentifierAsync( + context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+905551234567" + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_total_limit_exceeded*"); + } + + [Fact] + public async Task Adding_identifier_should_fail_when_type_limit_reached() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.Behavior.MaxIdentifiersPerType = new() + { + [UserIdentifierType.Email] = 1 + }; + }); + + var service = runtime.GetUserApplicationService(); + var context = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.AddSelf); + + var act = () => service.AddUserIdentifierAsync( + context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "another@example.com" + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_type_limit_exceeded*"); + } + + [Fact] + public async Task CreateUserAsync_WhenGlobalIdentifierLimitExceeded_ThrowsWithoutPersisting() + { + var f = CreateFixtureWithOptions(options => + { + options.Identifiers.Behavior.MaxIdentifiersPerUser = 2; + }); + + var context = CreateContext(); + + var request = new CreateUserRequest + { + UserName = "alice", + Email = "alice@example.com", + Phone = "+905551112233" + }; + + SetupValidCreate(f, context, request); + + var act = () => f.Sut.CreateUserAsync(context, request); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_total_limit_exceeded*"); + + f.LifecycleStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.ProfileStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.IdentifierStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CreateUserAsync_WhenIdentifierCountEqualsLimit_Succeeds() + { + var f = CreateFixtureWithOptions(options => + { + options.Identifiers.Behavior.MaxIdentifiersPerUser = 2; + }); + + var context = CreateContext(); + + var request = new CreateUserRequest + { + UserName = "alice", + Email = "alice@example.com" + }; + + SetupValidCreate(f, context, request); + SetupSuccessfulCreatePersistence(f); + + var result = await f.Sut.CreateUserAsync(context, request, ct: TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + + f.LifecycleStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + + f.ProfileStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task CreateUserAsync_WithDefaultLimit_AllowsThreeInitialIdentifiers() + { + var f = CreateFixture(); + var context = CreateContext(); + + var request = new CreateUserRequest + { + UserName = "alice", + Email = "alice@example.com", + Phone = "+905551112233" + }; + + SetupValidCreate(f, context, request); + SetupSuccessfulCreatePersistence(f); + + var result = await f.Sut.CreateUserAsync(context, request, ct: TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + + f.IdentifierStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Exactly(3)); + } + + [Fact] + public async Task CreateUserAsync_WhenProfileFieldExceedsLimit_DoesNotPersist() + { + var f = CreateFixture(); + var context = CreateContext(); + + var request = new CreateUserRequest + { + UserName = "alice", + FirstName = new string('x', UserProfileLimits.FirstName + 1) + }; + + // Simulate an application validator that accepts the request. + SetupValidCreate(f, context, request); + + var act = () => f.Sut.CreateUserAsync(context, request); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Code.Should().Be("profile_field_too_long"); + + f.LifecycleStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.ProfileStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.IdentifierStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.Atomic.Verify( + x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CreateProfileAsync_WhenMetadataExceedsLimit_DoesNotPersist() + { + var f = CreateFixtureWithOptions(options => + { + options.UserProfile.EnableMultiProfile = true; + }); + + var userKey = UserKey.New(); + + var context = CreateContext( + actorUserKey: userKey, + targetUserKey: userKey); + + var profileKey = ProfileKey.Parse("secondary", null); + + var request = new CreateProfileRequest + { + ProfileKey = profileKey, + Metadata = Enumerable + .Range(0, UserProfileLimits.MetadataEntries + 1) + .ToDictionary( + x => $"key{x}", + _ => "value") + }; + + f.ProfileStore + .Setup(x => x.ExistsAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + + var act = () => f.Sut.CreateProfileAsync(context, request); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Code.Should().Be("profile_metadata_entries_exceeded"); + + f.ProfileStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UpdateUserProfileAsync_WhenFieldExceedsLimit_DoesNotMutateOrPersist() + { + var f = CreateFixture(); + + var userKey = UserKey.New(); + + var context = CreateContext( + actorUserKey: userKey, + targetUserKey: userKey); + + var profile = UserProfile.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + ProfileKey.Default, + Now.AddDays(-1), + firstName: "Alice", + lastName: "Smith", + displayName: "Alice Smith"); + + var originalVersion = profile.Version; + var originalUpdatedAt = profile.UpdatedAt; + + f.ProfileStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(profile); + + var request = new UpdateProfileRequest + { + FirstName = new string('x', UserProfileLimits.FirstName + 1), + LastName = "Changed", + DisplayName = "Changed Name" + }; + + var act = () => f.Sut.UpdateUserProfileAsync(context, request); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Code.Should().Be("profile_field_too_long"); + + // The existing tracked entity must remain unchanged. + profile.FirstName.Should().Be("Alice"); + profile.LastName.Should().Be("Smith"); + profile.DisplayName.Should().Be("Alice Smith"); + + profile.Version.Should().Be(originalVersion); + profile.UpdatedAt.Should().Be(originalUpdatedAt); + + f.ProfileStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + // ============================================================ // Helpers // ============================================================ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrations) + => CreateFixtureWithOptions(null, integrations); + + private static Fixture CreateFixtureWithOptions(Action? configureOptions, params IUserLifecycleIntegration[] integrations) { var access = new Mock(MockBehavior.Strict); var atomic = new Mock(MockBehavior.Strict); @@ -2180,8 +2502,10 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat .Returns>, CancellationToken>( (operation, ct) => operation(ct)); - var options = Options.Create( - new UAuthServerOptions()); + var serverOptions = new UAuthServerOptions(); + configureOptions?.Invoke(serverOptions); + + var options = Options.Create(serverOptions); var sut = new UserApplicationService( access.Object, @@ -2234,6 +2558,7 @@ private static AccessContext CreateContext( targetUserKey: targetUserKey ?? actorUserKey, resourceTenant: TenantKey.Single, action: action, + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } @@ -2413,4 +2738,4 @@ private static UserIdentifier CreateIdentifier( deletedAt: null, version: version); } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs index 64aad5cd..307482f8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserCreateValidatorTests.cs @@ -25,7 +25,7 @@ public async Task ValidateAsync_WhenNoIdentifierProvided_ShouldReturnIdentifierR var result = await fixture.Sut.ValidateAsync( fixture.Context, - request); + request, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); @@ -72,7 +72,7 @@ public async Task ValidateAsync_WhenIdentifierIsInvalid_ShouldNotCheckAvailabili var result = await fixture.Sut.ValidateAsync( fixture.Context, - CreateRequest(type, "invalid-value")); + CreateRequest(type, "invalid-value"), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); @@ -121,7 +121,7 @@ public async Task ValidateAsync_WhenIdentifierIsUnavailable_ShouldReturnExpected var result = await fixture.Sut.ValidateAsync( fixture.Context, - CreateRequest(type, "value")); + CreateRequest(type, "value"), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); @@ -155,7 +155,7 @@ public async Task ValidateAsync_WhenIdentifierIsValidAndAvailable_ShouldSucceed( var result = await fixture.Sut.ValidateAsync( fixture.Context, - CreateRequest(type, "value")); + CreateRequest(type, "value"), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.Errors.Should().BeEmpty(); @@ -190,7 +190,7 @@ public async Task ValidateAsync_WhenAllIdentifiersProvided_ShouldValidateAllIden UserName = "john", Email = "john@example.com", Phone = "1234567890" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); @@ -238,7 +238,7 @@ await fixture.Sut.ValidateAsync( UserName = "john", Email = "john@example.com", Phone = "1234567890" - }); + }, ct: TestContext.Current.CancellationToken); fixture.IdentifierValidator.Verify( x => x.ValidateAsync( @@ -339,7 +339,7 @@ public async Task ValidateAsync_WhenOneIdentifierIsInvalid_ShouldContinueValidat { UserName = "invalid", Email = "john@example.com" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs index 97ea6934..02966ea8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -1,11 +1,12 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +ο»Ώusing AwesomeAssertions; +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.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using AwesomeAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Moq; @@ -1993,6 +1994,7 @@ private static AccessContext CreateAccessContext(Fixture f, UserKey? targetUserK targetUserKey: targetUserKey ?? f.Flow.UserKey, resourceTenant: f.Flow.Tenant, action: "test", + clientProfile: UAuthClientProfile.NotSpecified, attributes: EmptyAttributes.Instance); } @@ -2021,4 +2023,4 @@ private sealed record Fixture( Mock AccessContextFactory, Mock Users, DefaultHttpContext HttpContext); -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 4f31ff61..8cf080e5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -27,9 +27,9 @@ await service.AddUserIdentifierAsync(context, Type = UserIdentifierType.Email, Value = "new@example.com", IsPrimary = true - }); + }, ct: TestContext.Current.CancellationToken); - var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); identifiers.Items.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); @@ -61,14 +61,14 @@ await identifierService.AddUserIdentifierAsync(context, Type = UserIdentifierType.Phone, Value = "+905551111111", IsPrimary = true - }); + }, ct: TestContext.Current.CancellationToken); var result = await loginOrchestrator.LoginAsync(flow, new LoginRequest { Identifier = "+905551111111", Secret = "user", - }); + }, ct: TestContext.Current.CancellationToken); result.IsSuccess.Should().BeFalse(); } @@ -81,7 +81,7 @@ public async Task Adding_non_primary_should_not_affect_existing_primary() var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); - var before = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var before = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); var existingPrimaryEmail = before.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); await service.AddUserIdentifierAsync(context, @@ -90,9 +90,9 @@ await service.AddUserIdentifierAsync(context, Type = UserIdentifierType.Email, Value = "secondary@example.com", IsPrimary = false - }); + }, ct: TestContext.Current.CancellationToken); - var after = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var after = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); after.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) .Id.Should().Be(existingPrimaryEmail.Id); @@ -106,16 +106,16 @@ public async Task Updating_value_should_reset_verification() var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UpdateSelf); - var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email); + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken)).Items.Single(x => x.Type == UserIdentifierType.Email); await service.UpdateUserIdentifierAsync(context, new UpdateUserIdentifierRequest { Id = email.Id, NewValue = "updated@example.com" - }); + }, ct: TestContext.Current.CancellationToken); - var updated = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items + var updated = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken)).Items .Single(x => x.Id == email.Id); updated.IsVerified.Should().BeFalse(); @@ -146,7 +146,7 @@ await service.AddUserIdentifierAsync( Type = UserIdentifierType.Email, Value = "shared@example.com", IsPrimary = false - }); + }, ct: TestContext.Current.CancellationToken); Func act = () => service.AddUserIdentifierAsync( @@ -185,7 +185,7 @@ await service.AddUserIdentifierAsync( { Type = UserIdentifierType.Email, Value = "unique@example.com" - }); + }, ct: TestContext.Current.CancellationToken); Func act = () => service.AddUserIdentifierAsync( @@ -225,7 +225,7 @@ await service.AddUserIdentifierAsync( { Type = UserIdentifierType.Email, Value = "shared@example.com" - }); + }, ct: TestContext.Current.CancellationToken); Func act = () => service.AddUserIdentifierAsync( @@ -254,7 +254,7 @@ public async Task Unsetting_last_login_identifier_should_fail() 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); + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken)).Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); Func act = async () => await service.UnsetPrimaryUserIdentifierAsync(context, @@ -279,7 +279,7 @@ await service.AddUserIdentifierAsync(context, { Type = UserIdentifierType.Email, Value = "Test@Example.com" - }); + }, ct: TestContext.Current.CancellationToken); Func act = async () => await service.AddUserIdentifierAsync(context, @@ -309,9 +309,9 @@ await service.AddUserIdentifierAsync(context, { Type = UserIdentifierType.Username, Value = "UserName" - }); + }, ct: TestContext.Current.CancellationToken); - var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); identifiers.Items.Should().Contain(x => x.Value == "UserName"); } @@ -333,7 +333,7 @@ await service.AddUserIdentifierAsync(context, { Type = UserIdentifierType.Username, Value = "UserName" - }); + }, ct: TestContext.Current.CancellationToken); Func act = async () => await service.AddUserIdentifierAsync(context, @@ -359,9 +359,9 @@ await service.AddUserIdentifierAsync(context, { Type = UserIdentifierType.Phone, Value = "+90 (555) 123-45-67" - }); + }, ct: TestContext.Current.CancellationToken); - var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); identifiers.Items.Should().Contain(x => x.Type == UserIdentifierType.Phone && @@ -381,16 +381,16 @@ await service.AddUserIdentifierAsync(context, { Type = UserIdentifierType.Email, Value = "one@example.com" - }); + }, ct: TestContext.Current.CancellationToken); await service.AddUserIdentifierAsync(context, new AddUserIdentifierRequest { Type = UserIdentifierType.Email, Value = "two@example.com" - }); + }, ct: TestContext.Current.CancellationToken); - var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); var second = identifiers.Items.Single(x => x.Value == "two@example.com"); Func act = async () => @@ -420,7 +420,7 @@ public async Task Availability_should_return_available_for_unused_identifier() { Type = UserIdentifierType.Username, Value = "unused-user-name" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.IsAvailable.Should().BeTrue(); @@ -439,7 +439,7 @@ public async Task Availability_should_return_unavailable_for_existing_identifier var identifiers = await service.GetIdentifiersByUserAsync( context, - new UserIdentifierQuery()); + new UserIdentifierQuery(), ct: TestContext.Current.CancellationToken); var existing = identifiers.Items.First(); @@ -449,7 +449,7 @@ await service.GetIdentifiersByUserAsync( { Type = existing.Type, Value = existing.Value - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.IsAvailable.Should().BeFalse(); @@ -460,11 +460,11 @@ public async Task Availability_should_use_normalized_identifier() { var runtime = new TestAuthRuntime(); - var user = await runtime.CreateLoginUserAsync(); + var user = await runtime.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); const string storedEmail = "availability@example.com"; - await runtime.AddIdentifierAsync(user.UserKey, UserIdentifierType.Email, storedEmail); + await runtime.AddIdentifierAsync(user.UserKey, UserIdentifierType.Email, storedEmail, ct: TestContext.Current.CancellationToken); var service = runtime.GetUserApplicationService(); @@ -475,7 +475,7 @@ public async Task Availability_should_use_normalized_identifier() { Type = UserIdentifierType.Email, Value = " AVAILABILITY@EXAMPLE.COM " - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.IsAvailable.Should().BeFalse(); @@ -498,7 +498,7 @@ public async Task Availability_should_return_validation_errors_for_invalid_ident { Type = UserIdentifierType.Email, Value = "invalid" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.IsAvailable.Should().BeFalse(); @@ -511,12 +511,12 @@ public async Task Email_availability_should_be_case_insensitive_by_default() var runtime = new TestAuthRuntime(); var user = - await runtime.CreateLoginUserAsync(); + await runtime.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); await runtime.AddIdentifierAsync( user.UserKey, UserIdentifierType.Email, - "availability@example.com"); + "availability@example.com", ct: TestContext.Current.CancellationToken); var service = runtime.GetUserApplicationService(); @@ -532,7 +532,7 @@ await service.CheckIdentifierAvailabilityAsync( { Type = UserIdentifierType.Email, Value = "AVAILABILITY@EXAMPLE.COM" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.IsAvailable.Should().BeFalse(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs index 3b17c43d..5162951a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierValidationTests.cs @@ -20,7 +20,7 @@ public async Task ValidateAsync_WhenIdentifierIsEmpty_ShouldReturnIdentifierEmpt { Type = UserIdentifierType.Username, Value = " " - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.Errors.Should().ContainSingle(x => @@ -38,7 +38,7 @@ public async Task ValidateAsync_ShouldTrimIdentifierBeforeValidation() Value = " valid_user " }; - var result = await sut.ValidateAsync(null!, identifier); + var result = await sut.ValidateAsync(null!, identifier, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); identifier.Value.Should().Be("valid_user"); @@ -58,7 +58,7 @@ public async Task ValidateAsync_WhenUsernameIsTooShort_ShouldReturnUsernameTooSh { Type = UserIdentifierType.Username, Value = "abc" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(x => @@ -79,7 +79,7 @@ public async Task ValidateAsync_WhenUsernameIsTooLong_ShouldReturnUsernameTooLon { Type = UserIdentifierType.Username, Value = "abcdef" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(x => @@ -101,7 +101,7 @@ public async Task ValidateAsync_WhenUsernameDoesNotMatchAllowedRegex_ShouldRetur { Type = UserIdentifierType.Username, Value = "user123" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(x => @@ -123,7 +123,7 @@ public async Task ValidateAsync_WhenUsernameValidationIsDisabled_ShouldSkipUsern { Type = UserIdentifierType.Username, Value = "a" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); } @@ -139,7 +139,7 @@ public async Task ValidateAsync_WhenEmailDoesNotContainAt_ShouldReturnInvalidFor { Type = UserIdentifierType.Email, Value = "not-an-email" - }); + }, ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(x => @@ -160,7 +160,7 @@ public async Task ValidateAsync_WhenEmailIsTooShort_ShouldReturnEmailTooShort() { Type = UserIdentifierType.Email, Value = "a@b.co" - }); + }, ct: TestContext.Current.CancellationToken); result.Errors.Should().Contain(x => x.Code == "email_too_short"); @@ -180,7 +180,7 @@ public async Task ValidateAsync_WhenEmailIsTooLong_ShouldReturnEmailTooLong() { Type = UserIdentifierType.Email, Value = "abc@example.com" - }); + }, ct: TestContext.Current.CancellationToken); result.Errors.Should().Contain(x => x.Code == "email_too_long"); @@ -200,7 +200,7 @@ public async Task ValidateAsync_WhenPhoneIsTooShort_ShouldReturnPhoneTooShort() { Type = UserIdentifierType.Phone, Value = "123" - }); + }, ct: TestContext.Current.CancellationToken); result.Errors.Should().Contain(x => x.Code == "phone_too_short"); @@ -220,7 +220,7 @@ public async Task ValidateAsync_WhenPhoneIsTooLong_ShouldReturnPhoneTooLong() { Type = UserIdentifierType.Phone, Value = "123456789" - }); + }, ct: TestContext.Current.CancellationToken); result.Errors.Should().Contain(x => x.Code == "phone_too_long"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileSafetyValidationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileSafetyValidationTests.cs new file mode 100644 index 00000000..2d359ad8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileSafetyValidationTests.cs @@ -0,0 +1,135 @@ +ο»Ώusing AwesomeAssertions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserProfileSafetyValidationTests +{ + [Theory] + [InlineData(nameof(UserProfileInfo.FirstName), UserProfileLimits.FirstName)] + [InlineData(nameof(UserProfileInfo.LastName), UserProfileLimits.LastName)] + [InlineData(nameof(UserProfileInfo.DisplayName), UserProfileLimits.DisplayName)] + [InlineData(nameof(UserProfileInfo.Gender), UserProfileLimits.Gender)] + [InlineData(nameof(UserProfileInfo.Bio), UserProfileLimits.Bio)] + [InlineData(nameof(UserProfileInfo.Language), UserProfileLimits.Language)] + [InlineData(nameof(UserProfileInfo.TimeZone), UserProfileLimits.TimeZone)] + [InlineData(nameof(UserProfileInfo.Culture), UserProfileLimits.Culture)] + public void ProfileFields_AtLimit_AreAccepted(string property, int maxLength) + { + var profile = new UserProfileInfo(); + + typeof(UserProfileInfo) + .GetProperty(property)! + .SetValue(profile, new string('x', maxLength)); + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(nameof(UserProfileInfo.FirstName), UserProfileLimits.FirstName)] + [InlineData(nameof(UserProfileInfo.LastName), UserProfileLimits.LastName)] + [InlineData(nameof(UserProfileInfo.DisplayName), UserProfileLimits.DisplayName)] + [InlineData(nameof(UserProfileInfo.Gender), UserProfileLimits.Gender)] + [InlineData(nameof(UserProfileInfo.Bio), UserProfileLimits.Bio)] + [InlineData(nameof(UserProfileInfo.Language), UserProfileLimits.Language)] + [InlineData(nameof(UserProfileInfo.TimeZone), UserProfileLimits.TimeZone)] + [InlineData(nameof(UserProfileInfo.Culture), UserProfileLimits.Culture)] + public void ProfileFields_OverLimit_AreRejected(string property, int maxLength) + { + var profile = new UserProfileInfo(); + + typeof(UserProfileInfo) + .GetProperty(property)! + .SetValue(profile, new string('x', maxLength + 1)); + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().ContainSingle() + .Which.Code.Should().Be("profile_field_too_long"); + } + + [Fact] + public void Metadata_TooManyEntries_IsRejected() + { + var metadata = Enumerable + .Range(0, UserProfileLimits.MetadataEntries + 1) + .ToDictionary(x => $"key{x}", _ => "value"); + + var profile = new UserProfileInfo + { + Metadata = metadata + }; + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().Contain( + x => x.Code == "profile_metadata_entries_exceeded"); + } + + [Fact] + public void Metadata_KeyTooLong_IsRejected() + { + var profile = new UserProfileInfo + { + Metadata = new Dictionary + { + [new string('k', UserProfileLimits.MetadataKey + 1)] = "value" + } + }; + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().Contain( + x => x.Code == "profile_metadata_key_too_long"); + } + + [Fact] + public void Metadata_ValueTooLong_IsRejected() + { + var profile = new UserProfileInfo + { + Metadata = new Dictionary + { + ["key"] = new string( + 'v', + UserProfileLimits.MetadataValue + 1) + } + }; + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().Contain( + x => x.Code == "profile_metadata_value_too_long"); + } + + [Fact] + public void Metadata_TotalUtf8SizeExceeded_IsRejected() + { + var metadata = Enumerable.Range(0, 5) + .ToDictionary( + x => $"key{x}", + _ => new string('Γ©', 1000)); + + var profile = new UserProfileInfo + { + Metadata = metadata + }; + + var errors = UserProfileSafetyValidation.Validate(profile); + + errors.Should().Contain( + x => x.Code == "profile_metadata_too_large"); + } + + [Fact] + public void EmptyProfile_IsValid() + { + var errors = UserProfileSafetyValidation.Validate( + new UserProfileInfo()); + + errors.Should().BeEmpty(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs index f54acbb6..0bd77a0f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserProfileValidatorTests.cs @@ -14,7 +14,7 @@ public async Task ValidateAsync_CurrentImplementation_ShouldSucceed() var result = await sut.ValidateAsync( null!, - new UserProfileInfo()); + new UserProfileInfo(), ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.Errors.Should().BeEmpty(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs index c62d68dc..d3582c73 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs @@ -18,10 +18,17 @@ function createEnvironment(fetchImpl = async () => ({ get: () => "application/json" }, text: async () => '{"id":1}' -})) { +}), onNavigate = () => { }) { + const memoryStorage = () => { + const values = new Map(); + return { setItem: (key, value) => values.set(key, value), getItem: key => values.get(key) ?? null, removeItem: key => values.delete(key) }; + }; const window = { + localStorage: memoryStorage(), + sessionStorage: memoryStorage(), location: { - origin: "https://localhost:6130" + origin: "https://localhost:6130", + assign: url => onNavigate(url) } }; @@ -48,6 +55,47 @@ function createEnvironment(fetchImpl = async () => ({ return window.uauth; } +const HUB_ORIGIN = "https://localhost:6110"; + +function configureAntiforgery(uauth) { + uauth.antiforgery.configure({ + tokenUrl: `${HUB_ORIGIN}/auth/antiforgery/token`, + trustedOrigin: HUB_ORIGIN + }); +} + +function authUrl(path) { + return `${HUB_ORIGIN}${path}`; +} + + +function csrfAwareFetch(postHandler) { + return async (url, options = {}) => { + if (url === authUrl("/auth/antiforgery/token")) { + assert.equal(options.method, "GET"); + assert.equal(options.credentials, "include"); + + return { + ok: true, + status: 200, + json: async () => ({ + requestToken: "csrf-test-token", + headerName: "X-CSRF-TOKEN", + formFieldName: "__RequestVerificationToken" + }) + }; + } + + assert.equal(options.method, "POST"); + assert.equal( + options.headers["X-CSRF-TOKEN"], + "csrf-test-token" + ); + + return postHandler(url, options); + }; +} + test("validateAuthUrl accepts trusted origin", () => { const uauth = createEnvironment(); @@ -320,3 +368,299 @@ test("cancelResourceRequest aborts active request", async () => { false ); }); + + +test("postJson sends antiforgery token to trusted cross-origin UAuthHub", async () => { + const calls = []; + + const uauth = createEnvironment(async (url, options) => { + calls.push({ url, options }); + + if (url === "https://localhost:6110/auth/antiforgery/token") { + return { + ok: true, + status: 200, + json: async () => ({ + requestToken: "csrf-test-token", + headerName: "X-CSRF-TOKEN", + formFieldName: "__RequestVerificationToken" + }) + }; + } + + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify({ isSuccess: true }) + }; + }); + + uauth.antiforgery.configure({ + tokenUrl: "https://localhost:6110/auth/antiforgery/token", + trustedOrigin: "https://localhost:6110" + }); + + const result = await uauth.postJson({ + url: "https://localhost:6110/auth/try-login", + payload: { username: "test" }, + clientProfile: "BlazorWasm", + trustedOrigin: "https://localhost:6110" + }); + + assert.equal(result.status, 200); + assert.equal(calls.length, 2); + + assert.equal( + calls[0].url, + "https://localhost:6110/auth/antiforgery/token" + ); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.credentials, "include"); + + assert.equal( + calls[1].url, + "https://localhost:6110/auth/try-login" + ); + assert.equal(calls[1].options.method, "POST"); + assert.equal(calls[1].options.credentials, "include"); + assert.equal( + calls[1].options.headers["X-CSRF-TOKEN"], + "csrf-test-token" + ); +}); + + +test("postJson fails closed when antiforgery is not configured", async () => { + let fetchCalls = 0; + + const uauth = createEnvironment(async () => { + fetchCalls++; + throw new Error("Fetch must not be called"); + }); + + await assert.rejects( + uauth.postJson({ + url: authUrl("/auth/try-login"), + payload: { username: "test" }, + clientProfile: "BlazorServer", + trustedOrigin: HUB_ORIGIN + }), + /Authentication URL is required|antiforgery is not configured/i + ); + + assert.equal(fetchCalls, 0); +}); + +test("429 try-and-commit returns throttle metadata without committing or reading an auth-result body", async () => { + let calls = 0; + const uauth = createEnvironment(csrfAwareFetch(async () => { + calls++; + return { ok: false, status: 429, headers: { get: name => name === "Retry-After" ? "90" : null }, text: async () => "not-json" }; + })); + + configureAntiforgery(uauth); + + const result = await uauth.tryAndCommit({ + tryUrl: authUrl("/auth/try-login"), + commitUrl: authUrl("/auth/login"), + data: {}, + clientProfile: "BlazorServer", + trustedOrigin: HUB_ORIGIN + }); + assert.equal(result.status, 429); + assert.equal(result.errorCode, "rate_limited"); + assert.equal(result.retryAfter, "90"); + assert.equal(result.isSuccess, false); + assert.equal(calls, 1); +}); + +test("logout 429 is observable and never navigates or changes stored tokens", async () => { + let calls = 0; + + const uauth = createEnvironment(csrfAwareFetch(async (_url, request) => { + calls++; + + assert.equal(request.redirect, "error"); + assert.equal( + request.headers["X-UAuth-Logout-Mode"], + "observe" + ); + + return { + ok: false, + status: 429, + redirected: false, + headers: { + get: name => name === "Retry-After" ? "15" : null + }, + text: async () => "" + }; + }), () => assert.fail("Rejected logout must not navigate")); + + configureAntiforgery(uauth); + + uauth.storage.set("local", "access", "still-valid-access"); + uauth.storage.set("session", "refresh", "still-valid-refresh"); + + const result = await uauth.post({ + url: authUrl("/auth/logout"), + mode: "logout", + clientProfile: "BlazorServer", + trustedOrigin: HUB_ORIGIN + }); + + assert.equal(result.status, 429); + assert.equal(result.retryAfter, "15"); + assert.equal(uauth.storage.get("local", "access"), "still-valid-access"); + assert.equal(uauth.storage.get("session", "refresh"), "still-valid-refresh"); + assert.equal(calls, 1); +}); + +test("resource 429 preserves Retry-After without automatic retry", async () => { + let calls = 0; + const uauth = createEnvironment(async () => { + calls++; + return { ok: false, status: 429, headers: { get: name => name === "Retry-After" ? "Sun, 01 Nov 2026 12:01:00 GMT" : null }, text: async () => "" }; + }); + const result = await uauth.resourceRequest({ url: "/api/data", method: "GET" }); + assert.equal(result.status, 429); + assert.equal(result.retryAfter, "Sun, 01 Nov 2026 12:01:00 GMT"); + assert.equal(calls, 1); +}); + +test("refresh 429 leaves stored credentials untouched and sends only once", async () => { + let calls = 0; + const uauth = createEnvironment(csrfAwareFetch(async () => { + calls++; + return { ok: false, status: 429, headers: { get: () => null }, text: async () => "" }; + })); + + configureAntiforgery(uauth); + + uauth.storage.set("local", "access", "valid-access"); + uauth.storage.set("session", "refresh", "valid-refresh"); + const result = await uauth.post({ url: authUrl("/auth/refresh"), mode: "fetch", clientProfile: "BlazorWasm", trustedOrigin: HUB_ORIGIN }); + assert.equal(result.status, 429); + assert.equal(uauth.storage.get("local", "access"), "valid-access"); + assert.equal(uauth.storage.get("session", "refresh"), "valid-refresh"); + assert.equal(calls, 1); +}); + +test("successful observed logout navigates after confirmed success without replaying POST", async () => { + let calls = 0; + let navigation = null; + + const uauth = createEnvironment(csrfAwareFetch(async (_url, request) => { + calls++; + + assert.equal(request.redirect, "error"); + assert.equal( + request.headers["X-UAuth-Logout-Mode"], + "observe" + ); + + return { + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: "https://localhost:6130/login" + }) + }; + }), url => navigation = url); + + configureAntiforgery(uauth); + + const result = await uauth.post({ + url: authUrl("/auth/logout"), + mode: "logout", + clientProfile: "BlazorWasm", + trustedOrigin: HUB_ORIGIN + }); + + assert.equal(result.status, 200); + assert.equal(navigation, "https://localhost:6130/login"); + assert.equal(calls, 1); +}); + +test("observed logout rejects unsafe redirect protocols", async () => { + let navigation = null; + + const uauth = createEnvironment(csrfAwareFetch(async () => ({ + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: "javascript:alert(1)" + }) + })), url => navigation = url); + + configureAntiforgery(uauth); + + await assert.rejects( + uauth.post({ + url: authUrl("/auth/logout"), + mode: "logout", + clientProfile: "BlazorWasm", + trustedOrigin: HUB_ORIGIN + }), + /Invalid logout redirect protocol/ + ); + + assert.equal(navigation, null); +}); + +test("authenticated logout uses the same Device ID for CSRF token and POST", async () => { + const deviceId = "test-device"; + let tokenDeviceId = null; + let logoutDeviceId = null; + + const uauth = createEnvironment(async (url, options) => { + if (url === authUrl("/auth/antiforgery/token")) { + tokenDeviceId = options.headers?.["X-UDID"] ?? null; + + return { + ok: true, + status: 200, + json: async () => ({ + requestToken: "csrf-test-token", + headerName: "X-UAuth-CSRF", + formFieldName: "__RequestVerificationToken" + }) + }; + } + + assert.equal(url, authUrl("/auth/logout")); + + logoutDeviceId = options.headers?.["X-UDID"] ?? null; + + return { + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: null + }) + }; + }); + + configureAntiforgery(uauth); + + const result = await uauth.post({ + url: authUrl("/auth/logout"), + mode: "logout-observe", + clientProfile: "BlazorServer", + trustedOrigin: HUB_ORIGIN + }); + + assert.equal(result.status, 200); + assert.equal(logoutDeviceId, deviceId); + assert.equal(tokenDeviceId, deviceId); +}); diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 00000000..b17f63d7 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,13 @@ + + + + $(UltimateAuthLatestTargetFramework) + + Exe + true + + + + + +